Compare commits
132 Commits
198fbf6751
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e78f3ab8d | ||
|
|
386d0004ed | ||
|
|
e233cddcc8 | ||
|
|
e39ddd2143 | ||
|
|
b1f9f7cee0 | ||
|
|
916b204688 | ||
|
|
d45cb6a087 | ||
|
|
f574ffaf38 | ||
|
|
b866179521 | ||
|
|
3ba784154b | ||
|
|
96aaea23e4 | ||
|
|
4b1785fb85 | ||
|
|
8aba034344 | ||
|
|
19425c0f62 | ||
|
|
1443ff0d3a | ||
|
|
e48ec3c564 | ||
|
|
eeffe4c6f1 | ||
|
|
9a8305d986 | ||
|
|
6ca0b50403 | ||
|
|
ce7da1ff66 | ||
|
|
87f348462b | ||
|
|
dba0cd5882 | ||
|
|
78af7838bf | ||
|
|
b8bb7926c0 | ||
|
|
6e4656946c | ||
|
|
c94fbe4ab8 | ||
|
|
9ccf079a3a | ||
|
|
e0e9723b11 | ||
|
|
0d2759b0d4 | ||
|
|
f0e8cee850 | ||
|
|
981df2ee45 | ||
|
|
6bc1d86009 | ||
|
|
53a6500e6a | ||
|
|
75c4dbd1f0 | ||
|
|
5d48b804a5 | ||
|
|
80dca9fe67 | ||
|
|
78bd3c9267 | ||
|
|
c4ac38e41a | ||
|
|
84e253b61c | ||
|
|
8414305ea3 | ||
|
|
694ce218c9 | ||
|
|
ec98086e23 | ||
|
|
d87d22fa22 | ||
|
|
a21321bb3c | ||
|
|
7a91e40bb3 | ||
|
|
71604c6e41 | ||
|
|
2b4d9e9411 | ||
|
|
35d8c94c11 | ||
|
|
ce43cac145 | ||
|
|
b11f010938 | ||
|
|
e3b356e810 | ||
|
|
6bd75b0788 | ||
|
|
14bb375674 | ||
|
|
a33bf0e3a4 | ||
|
|
1241f5092e | ||
|
|
73728f6baf | ||
|
|
db90b9af2e | ||
|
|
7dd8bc4c91 | ||
|
|
ee6fb7a5bb | ||
|
|
1c4c93c547 | ||
|
|
bdb1789a10 | ||
|
|
4bf0d5052c | ||
|
|
d629b745c4 | ||
|
|
72dd1409e8 | ||
|
|
2729c3c713 | ||
|
|
4135deb110 | ||
|
|
fda97b3c05 | ||
|
|
55608c311d | ||
|
|
ead80025fc | ||
|
|
d000d9b456 | ||
|
|
ebfcc090d6 | ||
|
|
49c4f9ad7d | ||
|
|
0089a14204 | ||
|
|
0eb3b85f06 | ||
|
|
db0fdc58a1 | ||
|
|
0db0ddf3c4 | ||
|
|
df5e8204fa | ||
|
|
6f7bf818dd | ||
|
|
b3ea522e31 | ||
|
|
b267ae2c15 | ||
|
|
4706b21d22 | ||
|
|
96e65ffc0b | ||
|
|
348834250a | ||
|
|
31a5148263 | ||
|
|
35f28fd48d | ||
|
|
53a90fec33 | ||
|
|
e1a2850768 | ||
|
|
03ee16bb87 | ||
|
|
1313135020 | ||
|
|
85f2c42fc5 | ||
|
|
6318aec3cb | ||
|
|
056d864f80 | ||
|
|
ef88342bd0 | ||
|
|
d76b26b7ad | ||
|
|
c1dfbaa51e | ||
|
|
32644eb41e | ||
|
|
db5fea9f2a | ||
|
|
fba9714ede | ||
|
|
cebc1d1ec5 | ||
|
|
5aa79b587d | ||
|
|
2e089f7f77 | ||
|
|
fd52f8e13d | ||
|
|
8ac38cf264 | ||
|
|
66193a6461 | ||
|
|
64c9d7357a | ||
|
|
8aa2efdd9a | ||
|
|
4f3503e3f4 | ||
|
|
4235eda49a | ||
|
|
ad0e8b7923 | ||
|
|
446eb15c6b | ||
|
|
02a24877f7 | ||
|
|
f016004b2b | ||
|
|
a0248d976b | ||
|
|
99a880854a | ||
|
|
a3747138a4 | ||
|
|
287cc8a532 | ||
|
|
191f39cf5b | ||
|
|
543b3015ca | ||
|
|
9d3c866562 | ||
|
|
911880f1a0 | ||
|
|
b9d91c8f40 | ||
|
|
23193a3452 | ||
|
|
da6f95aead | ||
|
|
2f9a700e00 | ||
|
|
50cc4e76df | ||
|
|
941931934f | ||
|
|
9b245e9c51 | ||
|
|
b9708d5174 | ||
|
|
a038594130 | ||
|
|
9bab5f6c89 | ||
|
|
ebab856137 | ||
|
|
fa33e7cbcf |
@@ -17,6 +17,7 @@
|
||||
{"id":"fotospiel-app-38f","title":"Paddle catalog sync: surface last sync error/log context in admin","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:14.865414785+01:00","created_by":"soeren","updated_at":"2026-01-02T21:16:09.109922491+01:00","closed_at":"2026-01-02T21:16:09.109922491+01:00","close_reason":"Completed"}
|
||||
{"id":"fotospiel-app-3ut","title":"SEC-API-03 Synthetic monitoring + alert config","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:46.793875724+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:46.793875724+01:00"}
|
||||
{"id":"fotospiel-app-3xa","title":"Security review: event admin code audit (policies, PKCE, file handling)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:20.115675149+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:20.115675149+01:00"}
|
||||
{"id":"fotospiel-app-43mp","title":"Help-System für Event Admin PWA planen","notes":"Context help links wired into priority admin pages.","status":"in_progress","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-23T08:21:47.812129626+01:00","created_by":"Codex Agent","updated_at":"2026-01-23T09:19:45.828239299+01:00"}
|
||||
{"id":"fotospiel-app-4ar","title":"SEC-BILL-03 Failed capture notifications + ledger hook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:33.266516715+01:00","created_by":"soeren","updated_at":"2026-01-01T15:54:33.266516715+01:00"}
|
||||
{"id":"fotospiel-app-4en","title":"Add translations for Mobile Package Shop","description":"The new MobilePackageShopPage.tsx uses translation keys like 'shop.title', 'shop.legal.agb', etc. Ensure these are added to the management.json files for de and en.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T18:05:50.469751088+01:00","created_by":"soeren","updated_at":"2026-01-06T18:14:19.984343737+01:00","closed_at":"2026-01-06T18:14:19.984346372+01:00"}
|
||||
{"id":"fotospiel-app-4i4","title":"Security review: map roles/data","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:58.370301875+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:03.997327414+01:00","closed_at":"2026-01-01T16:03:03.997327414+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
@@ -37,6 +38,7 @@
|
||||
{"id":"fotospiel-app-5ie","title":"Help docs: Live Show how-to + recommended hardware (DE/EN)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:12:05.973844187+01:00","created_by":"soeren","updated_at":"2026-01-05T19:42:44.39939087+01:00","closed_at":"2026-01-05T19:42:44.39939087+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:13:54.925412888+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-539","type":"blocks","created_at":"2026-01-05T11:14:03.257649076+01:00","created_by":"soeren"}]}
|
||||
{"id":"fotospiel-app-5iy","title":"Security review: confirm env/header defaults","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:20.808188183+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:26.388002115+01:00","closed_at":"2026-01-01T16:03:26.388002115+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-5s3","title":"Localized SEO: canonical/hreflang tags + localized navigation","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:03.909947355+01:00","created_by":"soeren","updated_at":"2026-01-01T16:02:09.550647107+01:00","closed_at":"2026-01-01T16:02:09.550647107+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-5veo","title":"Investigate vite build timeout","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-21T12:49:14.166622473+01:00","created_by":"Codex Agent","updated_at":"2026-01-21T12:49:14.166622473+01:00"}
|
||||
{"id":"fotospiel-app-5zl","title":"Ensure checkout step 3 requires login for Paddle checkout","description":"Problem: Paddle checkout on step 3 fails when user is not logged in. Step 3 must enforce authentication before initializing Paddle checkout.\\n\\nSuggestions:\\n- Protect step 3 route/controller with auth middleware and redirect to login with intended return URL.\\n- Gate step 3 UI/CTA on auth state; show inline login prompt and disable Paddle until authenticated.\\n- Require auth in backend endpoint that creates Paddle transaction/session; return 401 and send user to login.\\n- Optionally preflight at end of step 2 to prompt login before advancing.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T12:31:43.215017311+01:00","created_by":"soeren","updated_at":"2026-01-04T12:42:45.088723058+01:00","closed_at":"2026-01-04T12:42:45.088723058+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-64l","title":"SEC-FE-01 CSP nonce/hashing rollout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:47.607047443+01:00","created_by":"soeren","updated_at":"2026-01-01T15:55:56.477104351+01:00","closed_at":"2026-01-01T15:55:56.477104351+01:00","close_reason":"Completed in codebase (verified) - duplicate of fotospiel-app-zli"}
|
||||
{"id":"fotospiel-app-6dp","title":"Coupon ops enhancements (redemption service, preview endpoint, widget, export)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:09.275919717+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:14.882264149+01:00","closed_at":"2026-01-01T16:09:14.882264149+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
fotospiel-app-spq8
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"database": "beads.db",
|
||||
"jsonl_export": "issues.jsonl"
|
||||
}
|
||||
"jsonl_export": "issues.jsonl",
|
||||
"last_bd_version": "0.49.0"
|
||||
}
|
||||
@@ -97,6 +97,11 @@ GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URI=${APP_URL}/checkout/auth/google/callback
|
||||
|
||||
# Facebook OAuth (Checkout comfort login)
|
||||
FACEBOOK_CLIENT_ID=
|
||||
FACEBOOK_CLIENT_SECRET=
|
||||
FACEBOOK_REDIRECT_URI=${APP_URL}/checkout/auth/facebook/callback
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
VITE_ENABLE_TENANT_SWITCHER=false
|
||||
REVENUECAT_WEBHOOK_SECRET=
|
||||
@@ -187,5 +192,9 @@ STORAGE_QUEUE_PENDING_EVENT_MINUTES=8
|
||||
STORAGE_QUEUE_FAILED_EVENT_THRESHOLD=2
|
||||
STORAGE_QUEUE_FAILED_EVENT_MINUTES=30
|
||||
STORAGE_QUEUE_GUEST_ALERT_TTL=30
|
||||
STORAGE_CHECKSUM_VALIDATION=true
|
||||
STORAGE_CHECKSUM_ALERT_WINDOW_MINUTES=60
|
||||
STORAGE_CHECKSUM_WARNING=1
|
||||
STORAGE_CHECKSUM_CRITICAL=5
|
||||
|
||||
|
||||
|
||||
@@ -148,867 +148,6 @@ var tokens = {
|
||||
size
|
||||
};
|
||||
|
||||
// node_modules/@tamagui/create-theme/dist/esm/isMinusZero.mjs
|
||||
function isMinusZero(value) {
|
||||
return 1 / value === Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
__name(isMinusZero, "isMinusZero");
|
||||
|
||||
// node_modules/@tamagui/create-theme/dist/esm/themeInfo.mjs
|
||||
var THEME_INFO = /* @__PURE__ */ new Map();
|
||||
var getThemeInfo = /* @__PURE__ */ __name((theme, name) => THEME_INFO.get(name || JSON.stringify(theme)), "getThemeInfo");
|
||||
var setThemeInfo = /* @__PURE__ */ __name((theme, info) => {
|
||||
const next = {
|
||||
...info,
|
||||
cache: /* @__PURE__ */ new Map()
|
||||
};
|
||||
THEME_INFO.set(info.name || JSON.stringify(theme), next), THEME_INFO.set(JSON.stringify(info.definition), next);
|
||||
}, "setThemeInfo");
|
||||
|
||||
// node_modules/@tamagui/create-theme/dist/esm/createTheme.mjs
|
||||
var identityCache = /* @__PURE__ */ new Map();
|
||||
function createTheme(palette, definition, options, name, skipCache = false) {
|
||||
const cacheKey = skipCache ? "" : JSON.stringify([name, palette, definition, options]);
|
||||
if (!skipCache && identityCache.has(cacheKey)) return identityCache.get(cacheKey);
|
||||
const theme = {
|
||||
...Object.fromEntries(Object.entries(definition).map(([key, offset]) => [key, getValue(palette, offset)])),
|
||||
...options?.nonInheritedValues
|
||||
};
|
||||
return setThemeInfo(theme, {
|
||||
palette,
|
||||
definition,
|
||||
options,
|
||||
name
|
||||
}), cacheKey && identityCache.set(cacheKey, theme), theme;
|
||||
}
|
||||
__name(createTheme, "createTheme");
|
||||
var getValue = /* @__PURE__ */ __name((palette, value) => {
|
||||
if (!palette) throw new Error("No palette!");
|
||||
if (typeof value == "string") return value;
|
||||
const max = palette.length - 1, next = (value === 0 ? !isMinusZero(value) : value >= 0) ? value : max + value, index = Math.min(Math.max(0, next), max);
|
||||
return palette[index];
|
||||
}, "getValue");
|
||||
|
||||
// node_modules/@tamagui/create-theme/dist/esm/helpers.mjs
|
||||
function objectEntries(obj) {
|
||||
return Object.entries(obj);
|
||||
}
|
||||
__name(objectEntries, "objectEntries");
|
||||
function objectFromEntries(arr) {
|
||||
return Object.fromEntries(arr);
|
||||
}
|
||||
__name(objectFromEntries, "objectFromEntries");
|
||||
|
||||
// node_modules/@tamagui/create-theme/dist/esm/masks.mjs
|
||||
var createMask = /* @__PURE__ */ __name((createMask2) => typeof createMask2 == "function" ? {
|
||||
name: createMask2.name || "unnamed",
|
||||
mask: createMask2
|
||||
} : createMask2, "createMask");
|
||||
var skipMask = {
|
||||
name: "skip-mask",
|
||||
mask: /* @__PURE__ */ __name((template, opts) => {
|
||||
const {
|
||||
skip
|
||||
} = opts;
|
||||
return Object.fromEntries(Object.entries(template).filter(([k]) => !skip || !(k in skip)).map(([k, v]) => [k, applyOverrides(k, v, opts)]));
|
||||
}, "mask")
|
||||
};
|
||||
function applyOverrides(key, value, opts) {
|
||||
let override, strategy = opts.overrideStrategy;
|
||||
const overrideSwap = opts.overrideSwap?.[key];
|
||||
if (typeof overrideSwap < "u") override = overrideSwap, strategy = "swap";
|
||||
else {
|
||||
const overrideShift = opts.overrideShift?.[key];
|
||||
if (typeof overrideShift < "u") override = overrideShift, strategy = "shift";
|
||||
else {
|
||||
const overrideDefault = opts.override?.[key];
|
||||
typeof overrideDefault < "u" && (override = overrideDefault, strategy = opts.overrideStrategy);
|
||||
}
|
||||
}
|
||||
return typeof override > "u" || typeof override == "string" ? value : strategy === "swap" ? override : value;
|
||||
}
|
||||
__name(applyOverrides, "applyOverrides");
|
||||
var createIdentityMask = /* @__PURE__ */ __name(() => ({
|
||||
name: "identity-mask",
|
||||
mask: /* @__PURE__ */ __name((template, opts) => skipMask.mask(template, opts), "mask")
|
||||
}), "createIdentityMask");
|
||||
var createInverseMask = /* @__PURE__ */ __name(() => ({
|
||||
name: "inverse-mask",
|
||||
mask: /* @__PURE__ */ __name((template, opts) => {
|
||||
const inversed = objectFromEntries(objectEntries(template).map(([k, v]) => [k, -v]));
|
||||
return skipMask.mask(inversed, opts);
|
||||
}, "mask")
|
||||
}), "createInverseMask");
|
||||
var createShiftMask = /* @__PURE__ */ __name(({
|
||||
inverse
|
||||
} = {}, defaultOptions) => ({
|
||||
name: "shift-mask",
|
||||
mask: /* @__PURE__ */ __name((template, opts) => {
|
||||
const {
|
||||
override,
|
||||
overrideStrategy = "shift",
|
||||
max: maxIn,
|
||||
palette,
|
||||
min = 0,
|
||||
strength = 1
|
||||
} = {
|
||||
...defaultOptions,
|
||||
...opts
|
||||
}, values = Object.entries(template), max = maxIn ?? (palette ? Object.values(palette).length - 1 : Number.POSITIVE_INFINITY), out = {};
|
||||
for (const [key, value] of values) {
|
||||
if (typeof value == "string") continue;
|
||||
if (typeof override?.[key] == "number") {
|
||||
const overrideVal = override[key];
|
||||
out[key] = overrideStrategy === "shift" ? value + overrideVal : overrideVal;
|
||||
continue;
|
||||
}
|
||||
if (typeof override?.[key] == "string") {
|
||||
out[key] = override[key];
|
||||
continue;
|
||||
}
|
||||
const isPositive = value === 0 ? !isMinusZero(value) : value >= 0, direction = isPositive ? 1 : -1, invert = inverse ? -1 : 1, next = value + strength * direction * invert, clamped = isPositive ? Math.max(min, Math.min(max, next)) : Math.min(-min, Math.max(-max, next));
|
||||
out[key] = clamped;
|
||||
}
|
||||
return skipMask.mask(out, opts);
|
||||
}, "mask")
|
||||
}), "createShiftMask");
|
||||
var createWeakenMask = /* @__PURE__ */ __name((defaultOptions) => ({
|
||||
name: "soften-mask",
|
||||
mask: createShiftMask({}, defaultOptions).mask
|
||||
}), "createWeakenMask");
|
||||
var createSoftenMask = createWeakenMask;
|
||||
var createStrengthenMask = /* @__PURE__ */ __name((defaultOptions) => ({
|
||||
name: "strengthen-mask",
|
||||
mask: createShiftMask({
|
||||
inverse: true
|
||||
}, defaultOptions).mask
|
||||
}), "createStrengthenMask");
|
||||
|
||||
// node_modules/@tamagui/create-theme/dist/esm/applyMask.mjs
|
||||
function applyMaskStateless(info, mask, options = {}, parentName) {
|
||||
const skip = {
|
||||
...options.skip
|
||||
};
|
||||
if (info.options?.nonInheritedValues) for (const key in info.options.nonInheritedValues) skip[key] = 1;
|
||||
const maskOptions = {
|
||||
parentName,
|
||||
palette: info.palette,
|
||||
...options,
|
||||
skip
|
||||
}, template = mask.mask(info.definition, maskOptions), theme = createTheme(info.palette, template);
|
||||
return {
|
||||
...info,
|
||||
cache: /* @__PURE__ */ new Map(),
|
||||
definition: template,
|
||||
theme
|
||||
};
|
||||
}
|
||||
__name(applyMaskStateless, "applyMaskStateless");
|
||||
|
||||
// node_modules/@tamagui/create-theme/dist/esm/combineMasks.mjs
|
||||
var combineMasks = /* @__PURE__ */ __name((...masks2) => ({
|
||||
name: "combine-mask",
|
||||
mask: /* @__PURE__ */ __name((template, opts) => {
|
||||
let current = getThemeInfo(template, opts.parentName), theme;
|
||||
for (const mask2 of masks2) {
|
||||
if (!current) throw new Error(`Nothing returned from mask: ${current}, for template: ${template} and mask: ${mask2.toString()}, given opts ${JSON.stringify(opts, null, 2)}`);
|
||||
const next = applyMaskStateless(current, mask2, opts);
|
||||
current = next, theme = next.theme;
|
||||
}
|
||||
return theme;
|
||||
}, "mask")
|
||||
}), "combineMasks");
|
||||
|
||||
// node_modules/color2k/dist/index.exports.import.es.mjs
|
||||
function guard(low, high, value) {
|
||||
return Math.min(Math.max(low, value), high);
|
||||
}
|
||||
__name(guard, "guard");
|
||||
var ColorError = class extends Error {
|
||||
static {
|
||||
__name(this, "ColorError");
|
||||
}
|
||||
constructor(color2) {
|
||||
super(`Failed to parse color: "${color2}"`);
|
||||
}
|
||||
};
|
||||
var ColorError$1 = ColorError;
|
||||
function parseToRgba(color2) {
|
||||
if (typeof color2 !== "string") throw new ColorError$1(color2);
|
||||
if (color2.trim().toLowerCase() === "transparent") return [0, 0, 0, 0];
|
||||
let normalizedColor = color2.trim();
|
||||
normalizedColor = namedColorRegex.test(color2) ? nameToHex(color2) : color2;
|
||||
const reducedHexMatch = reducedHexRegex.exec(normalizedColor);
|
||||
if (reducedHexMatch) {
|
||||
const arr = Array.from(reducedHexMatch).slice(1);
|
||||
return [...arr.slice(0, 3).map((x) => parseInt(r(x, 2), 16)), parseInt(r(arr[3] || "f", 2), 16) / 255];
|
||||
}
|
||||
const hexMatch = hexRegex.exec(normalizedColor);
|
||||
if (hexMatch) {
|
||||
const arr = Array.from(hexMatch).slice(1);
|
||||
return [...arr.slice(0, 3).map((x) => parseInt(x, 16)), parseInt(arr[3] || "ff", 16) / 255];
|
||||
}
|
||||
const rgbaMatch = rgbaRegex.exec(normalizedColor);
|
||||
if (rgbaMatch) {
|
||||
const arr = Array.from(rgbaMatch).slice(1);
|
||||
return [...arr.slice(0, 3).map((x) => parseInt(x, 10)), parseFloat(arr[3] || "1")];
|
||||
}
|
||||
const hslaMatch = hslaRegex.exec(normalizedColor);
|
||||
if (hslaMatch) {
|
||||
const [h, s, l, a] = Array.from(hslaMatch).slice(1).map(parseFloat);
|
||||
if (guard(0, 100, s) !== s) throw new ColorError$1(color2);
|
||||
if (guard(0, 100, l) !== l) throw new ColorError$1(color2);
|
||||
return [...hslToRgb(h, s, l), Number.isNaN(a) ? 1 : a];
|
||||
}
|
||||
throw new ColorError$1(color2);
|
||||
}
|
||||
__name(parseToRgba, "parseToRgba");
|
||||
function hash(str) {
|
||||
let hash2 = 5381;
|
||||
let i = str.length;
|
||||
while (i) {
|
||||
hash2 = hash2 * 33 ^ str.charCodeAt(--i);
|
||||
}
|
||||
return (hash2 >>> 0) % 2341;
|
||||
}
|
||||
__name(hash, "hash");
|
||||
var colorToInt = /* @__PURE__ */ __name((x) => parseInt(x.replace(/_/g, ""), 36), "colorToInt");
|
||||
var compressedColorMap = "1q29ehhb 1n09sgk7 1kl1ekf_ _yl4zsno 16z9eiv3 1p29lhp8 _bd9zg04 17u0____ _iw9zhe5 _to73___ _r45e31e _7l6g016 _jh8ouiv _zn3qba8 1jy4zshs 11u87k0u 1ro9yvyo 1aj3xael 1gz9zjz0 _3w8l4xo 1bf1ekf_ _ke3v___ _4rrkb__ 13j776yz _646mbhl _nrjr4__ _le6mbhl 1n37ehkb _m75f91n _qj3bzfz 1939yygw 11i5z6x8 _1k5f8xs 1509441m 15t5lwgf _ae2th1n _tg1ugcv 1lp1ugcv 16e14up_ _h55rw7n _ny9yavn _7a11xb_ 1ih442g9 _pv442g9 1mv16xof 14e6y7tu 1oo9zkds 17d1cisi _4v9y70f _y98m8kc 1019pq0v 12o9zda8 _348j4f4 1et50i2o _8epa8__ _ts6senj 1o350i2o 1mi9eiuo 1259yrp0 1ln80gnw _632xcoy 1cn9zldc _f29edu4 1n490c8q _9f9ziet 1b94vk74 _m49zkct 1kz6s73a 1eu9dtog _q58s1rz 1dy9sjiq __u89jo3 _aj5nkwg _ld89jo3 13h9z6wx _qa9z2ii _l119xgq _bs5arju 1hj4nwk9 1qt4nwk9 1ge6wau6 14j9zlcw 11p1edc_ _ms1zcxe _439shk6 _jt9y70f _754zsow 1la40eju _oq5p___ _x279qkz 1fa5r3rv _yd2d9ip _424tcku _8y1di2_ _zi2uabw _yy7rn9h 12yz980_ __39ljp6 1b59zg0x _n39zfzp 1fy9zest _b33k___ _hp9wq92 1il50hz4 _io472ub _lj9z3eo 19z9ykg0 _8t8iu3a 12b9bl4a 1ak5yw0o _896v4ku _tb8k8lv _s59zi6t _c09ze0p 1lg80oqn 1id9z8wb _238nba5 1kq6wgdi _154zssg _tn3zk49 _da9y6tc 1sg7cv4f _r12jvtt 1gq5fmkz 1cs9rvci _lp9jn1c _xw1tdnb 13f9zje6 16f6973h _vo7ir40 _bt5arjf _rc45e4t _hr4e100 10v4e100 _hc9zke2 _w91egv_ _sj2r1kk 13c87yx8 _vqpds__ _ni8ggk8 _tj9yqfb 1ia2j4r4 _7x9b10u 1fc9ld4j 1eq9zldr _5j9lhpx _ez9zl6o _md61fzm".split(" ").reduce((acc, next) => {
|
||||
const key = colorToInt(next.substring(0, 3));
|
||||
const hex = colorToInt(next.substring(3)).toString(16);
|
||||
let prefix = "";
|
||||
for (let i = 0; i < 6 - hex.length; i++) {
|
||||
prefix += "0";
|
||||
}
|
||||
acc[key] = `${prefix}${hex}`;
|
||||
return acc;
|
||||
}, {});
|
||||
function nameToHex(color2) {
|
||||
const normalizedColorName = color2.toLowerCase().trim();
|
||||
const result = compressedColorMap[hash(normalizedColorName)];
|
||||
if (!result) throw new ColorError$1(color2);
|
||||
return `#${result}`;
|
||||
}
|
||||
__name(nameToHex, "nameToHex");
|
||||
var r = /* @__PURE__ */ __name((str, amount) => Array.from(Array(amount)).map(() => str).join(""), "r");
|
||||
var reducedHexRegex = new RegExp(`^#${r("([a-f0-9])", 3)}([a-f0-9])?$`, "i");
|
||||
var hexRegex = new RegExp(`^#${r("([a-f0-9]{2})", 3)}([a-f0-9]{2})?$`, "i");
|
||||
var rgbaRegex = new RegExp(`^rgba?\\(\\s*(\\d+)\\s*${r(",\\s*(\\d+)\\s*", 2)}(?:,\\s*([\\d.]+))?\\s*\\)$`, "i");
|
||||
var hslaRegex = /^hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%(?:\s*,\s*([\d.]+))?\s*\)$/i;
|
||||
var namedColorRegex = /^[a-z]+$/i;
|
||||
var roundColor = /* @__PURE__ */ __name((color2) => {
|
||||
return Math.round(color2 * 255);
|
||||
}, "roundColor");
|
||||
var hslToRgb = /* @__PURE__ */ __name((hue, saturation, lightness) => {
|
||||
let l = lightness / 100;
|
||||
if (saturation === 0) {
|
||||
return [l, l, l].map(roundColor);
|
||||
}
|
||||
const huePrime = (hue % 360 + 360) % 360 / 60;
|
||||
const chroma = (1 - Math.abs(2 * l - 1)) * (saturation / 100);
|
||||
const secondComponent = chroma * (1 - Math.abs(huePrime % 2 - 1));
|
||||
let red3 = 0;
|
||||
let green3 = 0;
|
||||
let blue3 = 0;
|
||||
if (huePrime >= 0 && huePrime < 1) {
|
||||
red3 = chroma;
|
||||
green3 = secondComponent;
|
||||
} else if (huePrime >= 1 && huePrime < 2) {
|
||||
red3 = secondComponent;
|
||||
green3 = chroma;
|
||||
} else if (huePrime >= 2 && huePrime < 3) {
|
||||
green3 = chroma;
|
||||
blue3 = secondComponent;
|
||||
} else if (huePrime >= 3 && huePrime < 4) {
|
||||
green3 = secondComponent;
|
||||
blue3 = chroma;
|
||||
} else if (huePrime >= 4 && huePrime < 5) {
|
||||
red3 = secondComponent;
|
||||
blue3 = chroma;
|
||||
} else if (huePrime >= 5 && huePrime < 6) {
|
||||
red3 = chroma;
|
||||
blue3 = secondComponent;
|
||||
}
|
||||
const lightnessModification = l - chroma / 2;
|
||||
const finalRed = red3 + lightnessModification;
|
||||
const finalGreen = green3 + lightnessModification;
|
||||
const finalBlue = blue3 + lightnessModification;
|
||||
return [finalRed, finalGreen, finalBlue].map(roundColor);
|
||||
}, "hslToRgb");
|
||||
function parseToHsla(color2) {
|
||||
const [red3, green3, blue3, alpha] = parseToRgba(color2).map((value, index) => (
|
||||
// 3rd index is alpha channel which is already normalized
|
||||
index === 3 ? value : value / 255
|
||||
));
|
||||
const max = Math.max(red3, green3, blue3);
|
||||
const min = Math.min(red3, green3, blue3);
|
||||
const lightness = (max + min) / 2;
|
||||
if (max === min) return [0, 0, lightness, alpha];
|
||||
const delta = max - min;
|
||||
const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
|
||||
const hue = 60 * (red3 === max ? (green3 - blue3) / delta + (green3 < blue3 ? 6 : 0) : green3 === max ? (blue3 - red3) / delta + 2 : (red3 - green3) / delta + 4);
|
||||
return [hue, saturation, lightness, alpha];
|
||||
}
|
||||
__name(parseToHsla, "parseToHsla");
|
||||
function hsla(hue, saturation, lightness, alpha) {
|
||||
return `hsla(${(hue % 360).toFixed()}, ${guard(0, 100, saturation * 100).toFixed()}%, ${guard(0, 100, lightness * 100).toFixed()}%, ${parseFloat(guard(0, 1, alpha).toFixed(3))})`;
|
||||
}
|
||||
__name(hsla, "hsla");
|
||||
|
||||
// node_modules/@tamagui/theme-builder/dist/esm/helpers.mjs
|
||||
var objectKeys = /* @__PURE__ */ __name((obj) => Object.keys(obj), "objectKeys");
|
||||
function objectFromEntries2(arr) {
|
||||
return Object.fromEntries(arr);
|
||||
}
|
||||
__name(objectFromEntries2, "objectFromEntries");
|
||||
|
||||
// node_modules/@tamagui/theme-builder/dist/esm/defaultTemplates.mjs
|
||||
var getTemplates = /* @__PURE__ */ __name(() => {
|
||||
const lightTemplates = getBaseTemplates("light"), darkTemplates = getBaseTemplates("dark");
|
||||
return {
|
||||
...objectFromEntries2(objectKeys(lightTemplates).map((name) => [`light_${name}`, lightTemplates[name]])),
|
||||
...objectFromEntries2(objectKeys(darkTemplates).map((name) => [`dark_${name}`, darkTemplates[name]]))
|
||||
};
|
||||
}, "getTemplates");
|
||||
var getBaseTemplates = /* @__PURE__ */ __name((scheme) => {
|
||||
const isLight = scheme === "light", bgIndex = 6, lighten = isLight ? -1 : 1, darken = -lighten, borderColor = bgIndex + 3, baseColors = {
|
||||
color: -bgIndex,
|
||||
colorHover: -bgIndex - 1,
|
||||
colorPress: -bgIndex,
|
||||
colorFocus: -bgIndex - 1,
|
||||
placeholderColor: -bgIndex - 3,
|
||||
outlineColor: -2
|
||||
}, base = {
|
||||
accentBackground: 0,
|
||||
accentColor: -0,
|
||||
background0: 1,
|
||||
background02: 2,
|
||||
background04: 3,
|
||||
background06: 4,
|
||||
background08: 5,
|
||||
color1: bgIndex,
|
||||
color2: bgIndex + 1,
|
||||
color3: bgIndex + 2,
|
||||
color4: bgIndex + 3,
|
||||
color5: bgIndex + 4,
|
||||
color6: bgIndex + 5,
|
||||
color7: bgIndex + 6,
|
||||
color8: bgIndex + 7,
|
||||
color9: bgIndex + 8,
|
||||
color10: bgIndex + 9,
|
||||
color11: bgIndex + 10,
|
||||
color12: bgIndex + 11,
|
||||
color0: -1,
|
||||
color02: -2,
|
||||
color04: -3,
|
||||
color06: -4,
|
||||
color08: -5,
|
||||
// the background, color, etc keys here work like generics - they make it so you
|
||||
// can publish components for others to use without mandating a specific color scale
|
||||
// the @tamagui/button Button component looks for `$background`, so you set the
|
||||
// dark_red_Button theme to have a stronger background than the dark_red theme.
|
||||
background: bgIndex,
|
||||
backgroundHover: bgIndex + lighten,
|
||||
// always lighten on hover no matter the scheme
|
||||
backgroundPress: bgIndex + darken,
|
||||
// always darken on press no matter the theme
|
||||
backgroundFocus: bgIndex + darken,
|
||||
borderColor,
|
||||
borderColorHover: borderColor + lighten,
|
||||
borderColorPress: borderColor + darken,
|
||||
borderColorFocus: borderColor,
|
||||
...baseColors,
|
||||
colorTransparent: -1
|
||||
}, surface1 = {
|
||||
...baseColors,
|
||||
background: base.background + 1,
|
||||
backgroundHover: base.backgroundHover + 1,
|
||||
backgroundPress: base.backgroundPress + 1,
|
||||
backgroundFocus: base.backgroundFocus + 1,
|
||||
borderColor: base.borderColor + 1,
|
||||
borderColorHover: base.borderColorHover + 1,
|
||||
borderColorFocus: base.borderColorFocus + 1,
|
||||
borderColorPress: base.borderColorPress + 1
|
||||
}, surface2 = {
|
||||
...baseColors,
|
||||
background: base.background + 2,
|
||||
backgroundHover: base.backgroundHover + 2,
|
||||
backgroundPress: base.backgroundPress + 2,
|
||||
backgroundFocus: base.backgroundFocus + 2,
|
||||
borderColor: base.borderColor + 2,
|
||||
borderColorHover: base.borderColorHover + 2,
|
||||
borderColorFocus: base.borderColorFocus + 2,
|
||||
borderColorPress: base.borderColorPress + 2
|
||||
}, surface3 = {
|
||||
...baseColors,
|
||||
background: base.background + 3,
|
||||
backgroundHover: base.backgroundHover + 3,
|
||||
backgroundPress: base.backgroundPress + 3,
|
||||
backgroundFocus: base.backgroundFocus + 3,
|
||||
borderColor: base.borderColor + 3,
|
||||
borderColorHover: base.borderColorHover + 3,
|
||||
borderColorFocus: base.borderColorFocus + 3,
|
||||
borderColorPress: base.borderColorPress + 3
|
||||
}, alt1 = {
|
||||
color: base.color - 1,
|
||||
colorHover: base.colorHover - 1,
|
||||
colorPress: base.colorPress - 1,
|
||||
colorFocus: base.colorFocus - 1
|
||||
}, alt2 = {
|
||||
color: base.color - 2,
|
||||
colorHover: base.colorHover - 2,
|
||||
colorPress: base.colorPress - 2,
|
||||
colorFocus: base.colorFocus - 2
|
||||
}, inverse = Object.fromEntries(Object.entries(base).map(([key, index]) => [key, -index]));
|
||||
return {
|
||||
base,
|
||||
surface1,
|
||||
surface2,
|
||||
surface3,
|
||||
alt1,
|
||||
alt2,
|
||||
inverse
|
||||
};
|
||||
}, "getBaseTemplates");
|
||||
var defaultTemplates = getTemplates();
|
||||
|
||||
// node_modules/@tamagui/theme-builder/dist/esm/getThemeSuitePalettes.mjs
|
||||
var paletteSize = 12;
|
||||
var generateColorPalette = /* @__PURE__ */ __name(({
|
||||
palette: buildPalette,
|
||||
scheme
|
||||
}) => {
|
||||
if (!buildPalette) return [];
|
||||
const {
|
||||
anchors
|
||||
} = buildPalette;
|
||||
let palette = [];
|
||||
const add = /* @__PURE__ */ __name((h, s, l, a) => {
|
||||
palette.push(hsla(h, s, l, a ?? 1));
|
||||
}, "add"), numAnchors = Object.keys(anchors).length;
|
||||
for (const [anchorIndex, anchor] of anchors.entries()) {
|
||||
const [h, s, l, a] = [anchor.hue[scheme], anchor.sat[scheme], anchor.lum[scheme], anchor.alpha?.[scheme] ?? 1];
|
||||
if (anchorIndex !== 0) {
|
||||
const lastAnchor = anchors[anchorIndex - 1], steps = anchor.index - lastAnchor.index, lastHue = lastAnchor.hue[scheme], lastSat = lastAnchor.sat[scheme], lastLum = lastAnchor.lum[scheme], stepHue = (lastHue - h) / steps, stepSat = (lastSat - s) / steps, stepLum = (lastLum - l) / steps;
|
||||
for (let step = lastAnchor.index + 1; step < anchor.index; step++) {
|
||||
const str = anchor.index - step;
|
||||
add(h + stepHue * str, s + stepSat * str, l + stepLum * str);
|
||||
}
|
||||
}
|
||||
if (add(h, s, l, a), anchorIndex === numAnchors - 1 && palette.length < paletteSize) for (let step = anchor.index + 1; step < paletteSize; step++) add(h, s, l);
|
||||
}
|
||||
const background = palette[0], foreground = palette[palette.length - 1], transparentValues = [background, foreground].map((color2) => {
|
||||
const [h, s, l] = parseToHsla(color2);
|
||||
return [hsla(h, s, l, 0), hsla(h, s, l, 0.2), hsla(h, s, l, 0.4), hsla(h, s, l, 0.6), hsla(h, s, l, 0.8)];
|
||||
}), reverseForeground = [...transparentValues[1]].reverse();
|
||||
return palette = [...transparentValues[0], ...palette, ...reverseForeground], palette;
|
||||
}, "generateColorPalette");
|
||||
function getThemeSuitePalettes(palette) {
|
||||
return {
|
||||
light: generateColorPalette({
|
||||
palette,
|
||||
scheme: "light"
|
||||
}),
|
||||
dark: generateColorPalette({
|
||||
palette,
|
||||
scheme: "dark"
|
||||
})
|
||||
};
|
||||
}
|
||||
__name(getThemeSuitePalettes, "getThemeSuitePalettes");
|
||||
|
||||
// node_modules/@tamagui/theme-builder/dist/esm/createThemes.mjs
|
||||
var defaultPalettes = createPalettes(getThemesPalettes({
|
||||
base: {
|
||||
palette: ["#fff", "#000"]
|
||||
},
|
||||
accent: {
|
||||
palette: ["#ff0000", "#ff9999"]
|
||||
}
|
||||
}));
|
||||
function getSchemePalette(colors3) {
|
||||
return {
|
||||
light: colors3,
|
||||
dark: [...colors3].reverse()
|
||||
};
|
||||
}
|
||||
__name(getSchemePalette, "getSchemePalette");
|
||||
function getAnchors(palette) {
|
||||
const numItems = palette.light.length;
|
||||
return palette.light.map((lcolor, index) => {
|
||||
const dcolor = palette.dark[index], [lhue, lsat, llum, lalpha] = parseToHsla(lcolor), [dhue, dsat, dlum, dalpha] = parseToHsla(dcolor);
|
||||
return {
|
||||
index: spreadIndex(11, numItems, index),
|
||||
hue: {
|
||||
light: lhue,
|
||||
dark: dhue
|
||||
},
|
||||
sat: {
|
||||
light: lsat,
|
||||
dark: dsat
|
||||
},
|
||||
lum: {
|
||||
light: llum,
|
||||
dark: dlum
|
||||
},
|
||||
alpha: {
|
||||
light: lalpha,
|
||||
dark: dalpha
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
__name(getAnchors, "getAnchors");
|
||||
function spreadIndex(maxIndex, numItems, index) {
|
||||
return Math.round(index / (numItems - 1) * maxIndex);
|
||||
}
|
||||
__name(spreadIndex, "spreadIndex");
|
||||
function coerceSimplePaletteToSchemePalette(def) {
|
||||
return Array.isArray(def) ? getSchemePalette(def) : def;
|
||||
}
|
||||
__name(coerceSimplePaletteToSchemePalette, "coerceSimplePaletteToSchemePalette");
|
||||
function getThemesPalettes(props) {
|
||||
const base = coerceSimplePaletteToSchemePalette(props.base.palette), accent = props.accent ? coerceSimplePaletteToSchemePalette(props.accent.palette) : null, baseAnchors = getAnchors(base);
|
||||
function getSubThemesPalettes(defs) {
|
||||
return Object.fromEntries(Object.entries(defs).map(([key, value]) => [key, {
|
||||
name: key,
|
||||
anchors: value.palette ? getAnchors(coerceSimplePaletteToSchemePalette(value.palette)) : baseAnchors
|
||||
}]));
|
||||
}
|
||||
__name(getSubThemesPalettes, "getSubThemesPalettes");
|
||||
return {
|
||||
base: {
|
||||
name: "base",
|
||||
anchors: baseAnchors
|
||||
},
|
||||
...accent && {
|
||||
accent: {
|
||||
name: "accent",
|
||||
anchors: getAnchors(accent)
|
||||
}
|
||||
},
|
||||
...props.childrenThemes && getSubThemesPalettes(props.childrenThemes),
|
||||
...props.grandChildrenThemes && getSubThemesPalettes(props.grandChildrenThemes)
|
||||
};
|
||||
}
|
||||
__name(getThemesPalettes, "getThemesPalettes");
|
||||
function createPalettes(palettes) {
|
||||
const accentPalettes = palettes.accent ? getThemeSuitePalettes(palettes.accent) : null, basePalettes = getThemeSuitePalettes(palettes.base);
|
||||
return Object.fromEntries(Object.entries(palettes).flatMap(([name, palette]) => {
|
||||
const palettes2 = getThemeSuitePalettes(palette), oppositePalettes = name.startsWith("accent") ? basePalettes : accentPalettes || basePalettes;
|
||||
if (!oppositePalettes) return [];
|
||||
const oppositeLight = oppositePalettes.light, oppositeDark = oppositePalettes.dark, bgOffset = 7;
|
||||
return [[name === "base" ? "light" : `light_${name}`, [oppositeLight[bgOffset], ...palettes2.light, oppositeLight[oppositeLight.length - bgOffset - 1]]], [name === "base" ? "dark" : `dark_${name}`, [oppositeDark[oppositeDark.length - bgOffset - 1], ...palettes2.dark, oppositeDark[bgOffset]]]];
|
||||
}));
|
||||
}
|
||||
__name(createPalettes, "createPalettes");
|
||||
|
||||
// node_modules/@tamagui/theme-builder/dist/esm/defaultTemplatesStronger.mjs
|
||||
var getTemplates2 = /* @__PURE__ */ __name(() => {
|
||||
const lightTemplates = getBaseTemplates2("light"), darkTemplates = getBaseTemplates2("dark");
|
||||
return {
|
||||
...objectFromEntries2(objectKeys(lightTemplates).map((name) => [`light_${name}`, lightTemplates[name]])),
|
||||
...objectFromEntries2(objectKeys(darkTemplates).map((name) => [`dark_${name}`, darkTemplates[name]]))
|
||||
};
|
||||
}, "getTemplates");
|
||||
var getBaseTemplates2 = /* @__PURE__ */ __name((scheme) => {
|
||||
const isLight = scheme === "light", bgIndex = 6, lighten = isLight ? -1 : 1, darken = -lighten, borderColor = bgIndex + 3, baseColors = {
|
||||
color: -bgIndex,
|
||||
colorHover: -bgIndex - 1,
|
||||
colorPress: -bgIndex,
|
||||
colorFocus: -bgIndex - 1,
|
||||
placeholderColor: -bgIndex - 3,
|
||||
outlineColor: -2
|
||||
}, base = {
|
||||
accentBackground: 0,
|
||||
accentColor: -0,
|
||||
background0: 1,
|
||||
background02: 2,
|
||||
background04: 3,
|
||||
background06: 4,
|
||||
background08: 5,
|
||||
color1: bgIndex,
|
||||
color2: bgIndex + 1,
|
||||
color3: bgIndex + 2,
|
||||
color4: bgIndex + 3,
|
||||
color5: bgIndex + 4,
|
||||
color6: bgIndex + 5,
|
||||
color7: bgIndex + 6,
|
||||
color8: bgIndex + 7,
|
||||
color9: bgIndex + 8,
|
||||
color10: bgIndex + 9,
|
||||
color11: bgIndex + 10,
|
||||
color12: bgIndex + 11,
|
||||
color0: -1,
|
||||
color02: -2,
|
||||
color04: -3,
|
||||
color06: -4,
|
||||
color08: -5,
|
||||
// the background, color, etc keys here work like generics - they make it so you
|
||||
// can publish components for others to use without mandating a specific color scale
|
||||
// the @tamagui/button Button component looks for `$background`, so you set the
|
||||
// dark_red_Button theme to have a stronger background than the dark_red theme.
|
||||
background: bgIndex,
|
||||
backgroundHover: bgIndex + lighten,
|
||||
// always lighten on hover no matter the scheme
|
||||
backgroundPress: bgIndex + darken,
|
||||
// always darken on press no matter the theme
|
||||
backgroundFocus: bgIndex + darken,
|
||||
borderColor,
|
||||
borderColorHover: borderColor + lighten,
|
||||
borderColorPress: borderColor + darken,
|
||||
borderColorFocus: borderColor,
|
||||
...baseColors,
|
||||
colorTransparent: -1
|
||||
}, surface1 = {
|
||||
...baseColors,
|
||||
background: base.background + 2,
|
||||
backgroundHover: base.backgroundHover + 2,
|
||||
backgroundPress: base.backgroundPress + 2,
|
||||
backgroundFocus: base.backgroundFocus + 2,
|
||||
borderColor: base.borderColor + 2,
|
||||
borderColorHover: base.borderColorHover + 2,
|
||||
borderColorFocus: base.borderColorFocus + 2,
|
||||
borderColorPress: base.borderColorPress + 2
|
||||
}, surface2 = {
|
||||
...baseColors,
|
||||
background: base.background + 3,
|
||||
backgroundHover: base.backgroundHover + 3,
|
||||
backgroundPress: base.backgroundPress + 3,
|
||||
backgroundFocus: base.backgroundFocus + 3,
|
||||
borderColor: base.borderColor + 3,
|
||||
borderColorHover: base.borderColorHover + 3,
|
||||
borderColorFocus: base.borderColorFocus + 3,
|
||||
borderColorPress: base.borderColorPress + 3
|
||||
}, surface3 = {
|
||||
...baseColors,
|
||||
background: base.background + 4,
|
||||
backgroundHover: base.backgroundHover + 4,
|
||||
backgroundPress: base.backgroundPress + 4,
|
||||
backgroundFocus: base.backgroundFocus + 4,
|
||||
borderColor: base.borderColor + 4,
|
||||
borderColorHover: base.borderColorHover + 4,
|
||||
borderColorFocus: base.borderColorFocus + 4,
|
||||
borderColorPress: base.borderColorPress + 4
|
||||
}, alt1 = {
|
||||
color: base.color - 1,
|
||||
colorHover: base.colorHover - 1,
|
||||
colorPress: base.colorPress - 1,
|
||||
colorFocus: base.colorFocus - 1
|
||||
}, alt2 = {
|
||||
color: base.color - 2,
|
||||
colorHover: base.colorHover - 2,
|
||||
colorPress: base.colorPress - 2,
|
||||
colorFocus: base.colorFocus - 2
|
||||
}, inverse = Object.fromEntries(Object.entries(base).map(([key, index]) => [key, -index]));
|
||||
return {
|
||||
base,
|
||||
surface1,
|
||||
surface2,
|
||||
surface3,
|
||||
alt1,
|
||||
alt2,
|
||||
inverse
|
||||
};
|
||||
}, "getBaseTemplates");
|
||||
var defaultTemplatesStronger = getTemplates2();
|
||||
|
||||
// node_modules/@tamagui/theme-builder/dist/esm/defaultTemplatesStrongest.mjs
|
||||
var getTemplates3 = /* @__PURE__ */ __name(() => {
|
||||
const lightTemplates = getBaseTemplates3("light"), darkTemplates = getBaseTemplates3("dark");
|
||||
return {
|
||||
...objectFromEntries2(objectKeys(lightTemplates).map((name) => [`light_${name}`, lightTemplates[name]])),
|
||||
...objectFromEntries2(objectKeys(darkTemplates).map((name) => [`dark_${name}`, darkTemplates[name]]))
|
||||
};
|
||||
}, "getTemplates");
|
||||
var getBaseTemplates3 = /* @__PURE__ */ __name((scheme) => {
|
||||
const isLight = scheme === "light", bgIndex = 6, lighten = isLight ? -1 : 1, darken = -lighten, borderColor = bgIndex + 3, baseColors = {
|
||||
color: -bgIndex,
|
||||
colorHover: -bgIndex - 1,
|
||||
colorPress: -bgIndex,
|
||||
colorFocus: -bgIndex - 1,
|
||||
placeholderColor: -bgIndex - 3,
|
||||
outlineColor: -2
|
||||
}, base = {
|
||||
accentBackground: 0,
|
||||
accentColor: -0,
|
||||
background0: 1,
|
||||
background02: 2,
|
||||
background04: 3,
|
||||
background06: 4,
|
||||
background08: 5,
|
||||
color1: bgIndex,
|
||||
color2: bgIndex + 1,
|
||||
color3: bgIndex + 2,
|
||||
color4: bgIndex + 3,
|
||||
color5: bgIndex + 4,
|
||||
color6: bgIndex + 5,
|
||||
color7: bgIndex + 6,
|
||||
color8: bgIndex + 7,
|
||||
color9: bgIndex + 8,
|
||||
color10: bgIndex + 9,
|
||||
color11: bgIndex + 10,
|
||||
color12: bgIndex + 11,
|
||||
color0: -1,
|
||||
color02: -2,
|
||||
color04: -3,
|
||||
color06: -4,
|
||||
color08: -5,
|
||||
// the background, color, etc keys here work like generics - they make it so you
|
||||
// can publish components for others to use without mandating a specific color scale
|
||||
// the @tamagui/button Button component looks for `$background`, so you set the
|
||||
// dark_red_Button theme to have a stronger background than the dark_red theme.
|
||||
background: bgIndex,
|
||||
backgroundHover: bgIndex + lighten,
|
||||
// always lighten on hover no matter the scheme
|
||||
backgroundPress: bgIndex + darken,
|
||||
// always darken on press no matter the theme
|
||||
backgroundFocus: bgIndex + darken,
|
||||
borderColor,
|
||||
borderColorHover: borderColor + lighten,
|
||||
borderColorPress: borderColor + darken,
|
||||
borderColorFocus: borderColor,
|
||||
...baseColors,
|
||||
colorTransparent: -1
|
||||
}, surface1 = {
|
||||
...baseColors,
|
||||
background: base.background + 3,
|
||||
backgroundHover: base.backgroundHover + 3,
|
||||
backgroundPress: base.backgroundPress + 3,
|
||||
backgroundFocus: base.backgroundFocus + 3,
|
||||
borderColor: base.borderColor + 3,
|
||||
borderColorHover: base.borderColorHover + 3,
|
||||
borderColorFocus: base.borderColorFocus + 3,
|
||||
borderColorPress: base.borderColorPress + 3
|
||||
}, surface2 = {
|
||||
...baseColors,
|
||||
background: base.background + 4,
|
||||
backgroundHover: base.backgroundHover + 4,
|
||||
backgroundPress: base.backgroundPress + 4,
|
||||
backgroundFocus: base.backgroundFocus + 4,
|
||||
borderColor: base.borderColor + 4,
|
||||
borderColorHover: base.borderColorHover + 4,
|
||||
borderColorFocus: base.borderColorFocus + 4,
|
||||
borderColorPress: base.borderColorPress + 4
|
||||
}, surface3 = {
|
||||
...baseColors,
|
||||
background: base.background + 5,
|
||||
backgroundHover: base.backgroundHover + 5,
|
||||
backgroundPress: base.backgroundPress + 5,
|
||||
backgroundFocus: base.backgroundFocus + 5,
|
||||
borderColor: base.borderColor + 5,
|
||||
borderColorHover: base.borderColorHover + 5,
|
||||
borderColorFocus: base.borderColorFocus + 5,
|
||||
borderColorPress: base.borderColorPress + 5
|
||||
}, alt1 = {
|
||||
color: base.color - 1,
|
||||
colorHover: base.colorHover - 1,
|
||||
colorPress: base.colorPress - 1,
|
||||
colorFocus: base.colorFocus - 1
|
||||
}, alt2 = {
|
||||
color: base.color - 2,
|
||||
colorHover: base.colorHover - 2,
|
||||
colorPress: base.colorPress - 2,
|
||||
colorFocus: base.colorFocus - 2
|
||||
}, inverse = Object.fromEntries(Object.entries(base).map(([key, index]) => [key, -index]));
|
||||
return {
|
||||
base,
|
||||
surface1,
|
||||
surface2,
|
||||
surface3,
|
||||
alt1,
|
||||
alt2,
|
||||
inverse
|
||||
};
|
||||
}, "getBaseTemplates");
|
||||
var defaultTemplatesStrongest = getTemplates3();
|
||||
|
||||
// node_modules/@tamagui/theme-builder/dist/esm/masks.mjs
|
||||
var masks = {
|
||||
identity: createIdentityMask(),
|
||||
soften: createSoftenMask(),
|
||||
soften2: createSoftenMask({
|
||||
strength: 2
|
||||
}),
|
||||
soften3: createSoftenMask({
|
||||
strength: 3
|
||||
}),
|
||||
strengthen: createStrengthenMask(),
|
||||
inverse: createInverseMask(),
|
||||
inverseSoften: combineMasks(createInverseMask(), createSoftenMask({
|
||||
strength: 2
|
||||
})),
|
||||
inverseSoften2: combineMasks(createInverseMask(), createSoftenMask({
|
||||
strength: 3
|
||||
})),
|
||||
inverseSoften3: combineMasks(createInverseMask(), createSoftenMask({
|
||||
strength: 4
|
||||
})),
|
||||
inverseStrengthen2: combineMasks(createInverseMask(), createStrengthenMask({
|
||||
strength: 2
|
||||
})),
|
||||
strengthenButSoftenBorder: createMask((template, options) => {
|
||||
const stronger = createStrengthenMask().mask(template, options), softer = createSoftenMask().mask(template, options);
|
||||
return {
|
||||
...stronger,
|
||||
borderColor: softer.borderColor,
|
||||
borderColorHover: softer.borderColorHover,
|
||||
borderColorPress: softer.borderColorPress,
|
||||
borderColorFocus: softer.borderColorFocus
|
||||
};
|
||||
}),
|
||||
soften2Border1: createMask((template, options) => {
|
||||
const softer2 = createSoftenMask({
|
||||
strength: 2
|
||||
}).mask(template, options), softer1 = createSoftenMask({
|
||||
strength: 1
|
||||
}).mask(template, options);
|
||||
return {
|
||||
...softer2,
|
||||
borderColor: softer1.borderColor,
|
||||
borderColorHover: softer1.borderColorHover,
|
||||
borderColorPress: softer1.borderColorPress,
|
||||
borderColorFocus: softer1.borderColorFocus
|
||||
};
|
||||
}),
|
||||
soften3FlatBorder: createMask((template, options) => {
|
||||
const borderMask = createSoftenMask({
|
||||
strength: 2
|
||||
}).mask(template, options);
|
||||
return {
|
||||
...createSoftenMask({
|
||||
strength: 3
|
||||
}).mask(template, options),
|
||||
borderColor: borderMask.borderColor,
|
||||
borderColorHover: borderMask.borderColorHover,
|
||||
borderColorPress: borderMask.borderColorPress,
|
||||
borderColorFocus: borderMask.borderColorFocus
|
||||
};
|
||||
}),
|
||||
softenBorder: createMask((template, options) => {
|
||||
const plain = skipMask.mask(template, options), softer = createSoftenMask().mask(template, options);
|
||||
return {
|
||||
...plain,
|
||||
borderColor: softer.borderColor,
|
||||
borderColorHover: softer.borderColorHover,
|
||||
borderColorPress: softer.borderColorPress,
|
||||
borderColorFocus: softer.borderColorFocus
|
||||
};
|
||||
}),
|
||||
softenBorder2: createMask((template, options) => {
|
||||
const plain = skipMask.mask(template, options), softer = createSoftenMask({
|
||||
strength: 2
|
||||
}).mask(template, options);
|
||||
return {
|
||||
...plain,
|
||||
borderColor: softer.borderColor,
|
||||
borderColorHover: softer.borderColorHover,
|
||||
borderColorPress: softer.borderColorPress,
|
||||
borderColorFocus: softer.borderColorFocus
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
// node_modules/@tamagui/themes/dist/esm/generated-v4.mjs
|
||||
function t(a) {
|
||||
let res = {};
|
||||
@@ -4160,8 +3299,8 @@ var tokens3 = {
|
||||
...tokens2,
|
||||
color: {
|
||||
...tokens2.color,
|
||||
primary: "#4F46E5",
|
||||
// Indigo 600
|
||||
primary: "#FF5A5F",
|
||||
// Brand Rose
|
||||
accent: "#F43F5E",
|
||||
// Rose 500
|
||||
accentSoft: "#E0E7FF",
|
||||
@@ -4194,8 +3333,8 @@ var themes3 = {
|
||||
...themes2.light,
|
||||
primary: tokens3.color.primary,
|
||||
accent: tokens3.color.accent,
|
||||
background: "#F1F5F9",
|
||||
// Slate 100
|
||||
background: "#FFF8F5",
|
||||
// Brand Cream
|
||||
backgroundHover: "#E2E8F0",
|
||||
backgroundPress: "#CBD5E1",
|
||||
backgroundStrong: tokens3.color.surface,
|
||||
@@ -4223,7 +3362,7 @@ var themes3 = {
|
||||
...themes2.dark,
|
||||
primary: tokens3.color.primary,
|
||||
accent: tokens3.color.accent,
|
||||
background: "#0B132B",
|
||||
background: "#171219",
|
||||
backgroundHover: "#101A36",
|
||||
backgroundPress: "#132142",
|
||||
backgroundStrong: "#101A36",
|
||||
@@ -4263,12 +3402,12 @@ var fonts2 = {
|
||||
},
|
||||
heading: {
|
||||
...defaultConfig.fonts.heading,
|
||||
family: "Archivo Black",
|
||||
family: "Fraunces",
|
||||
weight: sharedWeights
|
||||
},
|
||||
display: {
|
||||
...defaultConfig.fonts.heading,
|
||||
family: "Archivo Black",
|
||||
family: "Fraunces",
|
||||
weight: sharedWeights
|
||||
}
|
||||
};
|
||||
|
||||
851538
.tamagui/tamagui.config.json
851538
.tamagui/tamagui.config.json
File diff suppressed because it is too large
Load Diff
@@ -118,319 +118,8 @@ var isWindowDefined = typeof window < "u";
|
||||
var isClient = isWeb && isWindowDefined;
|
||||
var isChrome = typeof navigator < "u" && /Chrome/.test(navigator.userAgent || "");
|
||||
var isWebTouchable = isClient && ("ontouchstart" in window || navigator.maxTouchPoints > 0);
|
||||
var isAndroid = false;
|
||||
var isIos = process.env.TEST_NATIVE_PLATFORM === "ios";
|
||||
|
||||
// node_modules/@tamagui/helpers/dist/esm/validStyleProps.mjs
|
||||
var textColors = {
|
||||
color: true,
|
||||
textDecorationColor: true,
|
||||
textShadowColor: true
|
||||
};
|
||||
var tokenCategories = {
|
||||
radius: {
|
||||
borderRadius: true,
|
||||
borderTopLeftRadius: true,
|
||||
borderTopRightRadius: true,
|
||||
borderBottomLeftRadius: true,
|
||||
borderBottomRightRadius: true,
|
||||
// logical
|
||||
borderStartStartRadius: true,
|
||||
borderStartEndRadius: true,
|
||||
borderEndStartRadius: true,
|
||||
borderEndEndRadius: true
|
||||
},
|
||||
size: {
|
||||
width: true,
|
||||
height: true,
|
||||
minWidth: true,
|
||||
minHeight: true,
|
||||
maxWidth: true,
|
||||
maxHeight: true,
|
||||
blockSize: true,
|
||||
minBlockSize: true,
|
||||
maxBlockSize: true,
|
||||
inlineSize: true,
|
||||
minInlineSize: true,
|
||||
maxInlineSize: true
|
||||
},
|
||||
zIndex: {
|
||||
zIndex: true
|
||||
},
|
||||
color: {
|
||||
backgroundColor: true,
|
||||
borderColor: true,
|
||||
borderBlockStartColor: true,
|
||||
borderBlockEndColor: true,
|
||||
borderBlockColor: true,
|
||||
borderBottomColor: true,
|
||||
borderInlineColor: true,
|
||||
borderInlineStartColor: true,
|
||||
borderInlineEndColor: true,
|
||||
borderTopColor: true,
|
||||
borderLeftColor: true,
|
||||
borderRightColor: true,
|
||||
borderEndColor: true,
|
||||
borderStartColor: true,
|
||||
shadowColor: true,
|
||||
...textColors,
|
||||
outlineColor: true,
|
||||
caretColor: true
|
||||
}
|
||||
};
|
||||
var stylePropsUnitless = {
|
||||
WebkitLineClamp: true,
|
||||
animationIterationCount: true,
|
||||
aspectRatio: true,
|
||||
borderImageOutset: true,
|
||||
borderImageSlice: true,
|
||||
borderImageWidth: true,
|
||||
columnCount: true,
|
||||
flex: true,
|
||||
flexGrow: true,
|
||||
flexOrder: true,
|
||||
flexPositive: true,
|
||||
flexShrink: true,
|
||||
flexNegative: true,
|
||||
fontWeight: true,
|
||||
gridRow: true,
|
||||
gridRowEnd: true,
|
||||
gridRowGap: true,
|
||||
gridRowStart: true,
|
||||
gridColumn: true,
|
||||
gridColumnEnd: true,
|
||||
gridColumnGap: true,
|
||||
gridColumnStart: true,
|
||||
gridTemplateColumns: true,
|
||||
gridTemplateAreas: true,
|
||||
lineClamp: true,
|
||||
opacity: true,
|
||||
order: true,
|
||||
orphans: true,
|
||||
tabSize: true,
|
||||
widows: true,
|
||||
zIndex: true,
|
||||
zoom: true,
|
||||
scale: true,
|
||||
scaleX: true,
|
||||
scaleY: true,
|
||||
scaleZ: true,
|
||||
shadowOpacity: true
|
||||
};
|
||||
var stylePropsTransform = {
|
||||
x: true,
|
||||
y: true,
|
||||
scale: true,
|
||||
perspective: true,
|
||||
scaleX: true,
|
||||
scaleY: true,
|
||||
skewX: true,
|
||||
skewY: true,
|
||||
matrix: true,
|
||||
rotate: true,
|
||||
rotateY: true,
|
||||
rotateX: true,
|
||||
rotateZ: true
|
||||
};
|
||||
var stylePropsView = {
|
||||
backfaceVisibility: true,
|
||||
borderBottomEndRadius: true,
|
||||
borderBottomStartRadius: true,
|
||||
borderBottomWidth: true,
|
||||
borderLeftWidth: true,
|
||||
borderRightWidth: true,
|
||||
borderBlockWidth: true,
|
||||
borderBlockEndWidth: true,
|
||||
borderBlockStartWidth: true,
|
||||
borderInlineWidth: true,
|
||||
borderInlineEndWidth: true,
|
||||
borderInlineStartWidth: true,
|
||||
borderStyle: true,
|
||||
borderBlockStyle: true,
|
||||
borderBlockEndStyle: true,
|
||||
borderBlockStartStyle: true,
|
||||
borderInlineStyle: true,
|
||||
borderInlineEndStyle: true,
|
||||
borderInlineStartStyle: true,
|
||||
borderTopEndRadius: true,
|
||||
borderTopStartRadius: true,
|
||||
borderTopWidth: true,
|
||||
borderWidth: true,
|
||||
transform: true,
|
||||
transformOrigin: true,
|
||||
alignContent: true,
|
||||
alignItems: true,
|
||||
alignSelf: true,
|
||||
borderEndWidth: true,
|
||||
borderStartWidth: true,
|
||||
bottom: true,
|
||||
display: true,
|
||||
end: true,
|
||||
flexBasis: true,
|
||||
flexDirection: true,
|
||||
flexWrap: true,
|
||||
gap: true,
|
||||
columnGap: true,
|
||||
rowGap: true,
|
||||
justifyContent: true,
|
||||
left: true,
|
||||
margin: true,
|
||||
marginBlock: true,
|
||||
marginBlockEnd: true,
|
||||
marginBlockStart: true,
|
||||
marginInline: true,
|
||||
marginInlineStart: true,
|
||||
marginInlineEnd: true,
|
||||
marginBottom: true,
|
||||
marginEnd: true,
|
||||
marginHorizontal: true,
|
||||
marginLeft: true,
|
||||
marginRight: true,
|
||||
marginStart: true,
|
||||
marginTop: true,
|
||||
marginVertical: true,
|
||||
overflow: true,
|
||||
padding: true,
|
||||
paddingBottom: true,
|
||||
paddingInline: true,
|
||||
paddingBlock: true,
|
||||
paddingBlockStart: true,
|
||||
paddingInlineEnd: true,
|
||||
paddingInlineStart: true,
|
||||
paddingEnd: true,
|
||||
paddingHorizontal: true,
|
||||
paddingLeft: true,
|
||||
paddingRight: true,
|
||||
paddingStart: true,
|
||||
paddingTop: true,
|
||||
paddingVertical: true,
|
||||
position: true,
|
||||
right: true,
|
||||
start: true,
|
||||
top: true,
|
||||
inset: true,
|
||||
insetBlock: true,
|
||||
insetBlockEnd: true,
|
||||
insetBlockStart: true,
|
||||
insetInline: true,
|
||||
insetInlineEnd: true,
|
||||
insetInlineStart: true,
|
||||
direction: true,
|
||||
shadowOffset: true,
|
||||
shadowRadius: true,
|
||||
...tokenCategories.color,
|
||||
...tokenCategories.radius,
|
||||
...tokenCategories.size,
|
||||
...tokenCategories.radius,
|
||||
...stylePropsTransform,
|
||||
...stylePropsUnitless,
|
||||
boxShadow: true,
|
||||
filter: true,
|
||||
// RN 0.77+ style props (set REACT_NATIVE_PRE_77=1 for older RN)
|
||||
...!process.env.REACT_NATIVE_PRE_77 && {
|
||||
boxSizing: true,
|
||||
mixBlendMode: true,
|
||||
outlineColor: true,
|
||||
outlineSpread: true,
|
||||
outlineStyle: true,
|
||||
outlineWidth: true
|
||||
},
|
||||
// RN doesn't support specific border styles per-edge
|
||||
transition: true,
|
||||
textWrap: true,
|
||||
backdropFilter: true,
|
||||
WebkitBackdropFilter: true,
|
||||
background: true,
|
||||
backgroundAttachment: true,
|
||||
backgroundBlendMode: true,
|
||||
backgroundClip: true,
|
||||
backgroundColor: true,
|
||||
backgroundImage: true,
|
||||
backgroundOrigin: true,
|
||||
backgroundPosition: true,
|
||||
backgroundRepeat: true,
|
||||
backgroundSize: true,
|
||||
borderBottomStyle: true,
|
||||
borderImage: true,
|
||||
borderLeftStyle: true,
|
||||
borderRightStyle: true,
|
||||
borderTopStyle: true,
|
||||
caretColor: true,
|
||||
clipPath: true,
|
||||
contain: true,
|
||||
containerType: true,
|
||||
content: true,
|
||||
cursor: true,
|
||||
float: true,
|
||||
mask: true,
|
||||
maskBorder: true,
|
||||
maskBorderMode: true,
|
||||
maskBorderOutset: true,
|
||||
maskBorderRepeat: true,
|
||||
maskBorderSlice: true,
|
||||
maskBorderSource: true,
|
||||
maskBorderWidth: true,
|
||||
maskClip: true,
|
||||
maskComposite: true,
|
||||
maskImage: true,
|
||||
maskMode: true,
|
||||
maskOrigin: true,
|
||||
maskPosition: true,
|
||||
maskRepeat: true,
|
||||
maskSize: true,
|
||||
maskType: true,
|
||||
objectFit: true,
|
||||
objectPosition: true,
|
||||
outlineOffset: true,
|
||||
overflowBlock: true,
|
||||
overflowInline: true,
|
||||
overflowX: true,
|
||||
overflowY: true,
|
||||
pointerEvents: true,
|
||||
scrollbarWidth: true,
|
||||
textEmphasis: true,
|
||||
touchAction: true,
|
||||
transformStyle: true,
|
||||
userSelect: true,
|
||||
willChange: true,
|
||||
...isAndroid ? {
|
||||
elevationAndroid: true
|
||||
} : {}
|
||||
};
|
||||
var stylePropsFont = {
|
||||
fontFamily: true,
|
||||
fontSize: true,
|
||||
fontStyle: true,
|
||||
fontWeight: true,
|
||||
fontVariant: true,
|
||||
letterSpacing: true,
|
||||
lineHeight: true,
|
||||
textTransform: true
|
||||
};
|
||||
var stylePropsTextOnly = {
|
||||
...stylePropsFont,
|
||||
textAlign: true,
|
||||
textDecorationLine: true,
|
||||
textDecorationStyle: true,
|
||||
...textColors,
|
||||
textShadowOffset: true,
|
||||
textShadowRadius: true,
|
||||
userSelect: true,
|
||||
selectable: true,
|
||||
verticalAlign: true,
|
||||
whiteSpace: true,
|
||||
wordWrap: true,
|
||||
textOverflow: true,
|
||||
textDecorationDistance: true,
|
||||
cursor: true,
|
||||
WebkitLineClamp: true,
|
||||
WebkitBoxOrient: true
|
||||
};
|
||||
var stylePropsText = {
|
||||
...stylePropsView,
|
||||
...stylePropsTextOnly
|
||||
};
|
||||
|
||||
// node_modules/@tamagui/helpers/dist/esm/withStaticProperties.mjs
|
||||
var import_react2 = __toESM(require("react"), 1);
|
||||
var Decorated = Symbol();
|
||||
@@ -755,7 +444,10 @@ var SizableText2 = (0, import_web4.styled)(import_web4.Text, {
|
||||
}
|
||||
});
|
||||
SizableText2.staticConfig.variants.fontFamily = {
|
||||
"...": /* @__PURE__ */ __name((_val, extras) => {
|
||||
"...": /* @__PURE__ */ __name((val, extras) => {
|
||||
if (val === "inherit") return {
|
||||
fontFamily: "inherit"
|
||||
};
|
||||
const sizeProp = extras.props.size, fontSizeProp = extras.props.fontSize, size = sizeProp === "$true" && fontSizeProp ? fontSizeProp : extras.props.size || "$true";
|
||||
return getFontSized(size, extras);
|
||||
}, "...")
|
||||
|
||||
@@ -112,7 +112,10 @@ var SizableText2 = (0, import_web2.styled)(import_web2.Text, {
|
||||
}
|
||||
});
|
||||
SizableText2.staticConfig.variants.fontFamily = {
|
||||
"...": /* @__PURE__ */ __name((_val, extras) => {
|
||||
"...": /* @__PURE__ */ __name((val, extras) => {
|
||||
if (val === "inherit") return {
|
||||
fontFamily: "inherit"
|
||||
};
|
||||
const sizeProp = extras.props.size, fontSizeProp = extras.props.fontSize, size = sizeProp === "$true" && fontSizeProp ? fontSizeProp : extras.props.size || "$true";
|
||||
return getFontSized(size, extras);
|
||||
}, "...")
|
||||
|
||||
@@ -38,6 +38,9 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
||||
- resources/js/admin/ — Tenant Admin PWA source (React 19, Capacitor/TWA ready).
|
||||
- resources/js/pages/ — Inertia pages (React).
|
||||
- docs/archive/README.md — historical PRP context.
|
||||
- Marketing frontend language files:
|
||||
- Source translations: `resources/lang/{de,en}/marketing.php` and `resources/lang/{de,en}/marketing.json`.
|
||||
- Runtime i18next JSON served to the frontend: `public/lang/{de,en}/marketing.json` (must stay in sync with the source files).
|
||||
|
||||
## Standard Workflows
|
||||
- Coding tasks (Codegen Agent):
|
||||
|
||||
@@ -100,6 +100,8 @@ COPY . .
|
||||
COPY --from=vendor /var/www/html/vendor ./vendor
|
||||
COPY --from=node_builder /var/www/html/public/build ./public/build
|
||||
|
||||
RUN php artisan vendor:publish --tag=livewire:assets --force --no-interaction
|
||||
|
||||
RUN php artisan config:clear \
|
||||
&& php artisan config:cache \
|
||||
&& php artisan route:clear \
|
||||
|
||||
@@ -7,7 +7,6 @@ use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Attributes\AsCommand;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
#[AsCommand(name: 'tenant:attach-demo-event')]
|
||||
class AttachDemoEvent extends Command
|
||||
@@ -25,10 +24,12 @@ class AttachDemoEvent extends Command
|
||||
{
|
||||
if (! \Illuminate\Support\Facades\Schema::hasTable('events')) {
|
||||
$this->error("Table 'events' does not exist. Run: php artisan migrate");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
if (! \Illuminate\Support\Facades\Schema::hasColumn('events', 'tenant_id')) {
|
||||
$this->error("Column 'events.tenant_id' does not exist. Add it and rerun. Suggested: create a migration to add a nullable foreignId to tenants.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$tenant = null;
|
||||
@@ -45,6 +46,7 @@ class AttachDemoEvent extends Command
|
||||
}
|
||||
if (! $tenant) {
|
||||
$this->error('Tenant not found. Provide --tenant-slug or a user with tenant_id via --tenant-email.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
@@ -67,12 +69,14 @@ class AttachDemoEvent extends Command
|
||||
|
||||
if (! $event) {
|
||||
$this->error('Event not found. Provide --event-id or --event-slug.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Idempotent update
|
||||
if ((int) $event->tenant_id === (int) $tenant->id) {
|
||||
$this->info("Event #{$event->id} already attached to tenant #{$tenant->id} ({$tenant->slug}).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
@@ -80,6 +84,7 @@ class AttachDemoEvent extends Command
|
||||
$event->save();
|
||||
|
||||
$this->info("Attached event #{$event->id} ({$event->slug}) to tenant #{$tenant->id} ({$tenant->slug}).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,22 +10,27 @@ use Illuminate\Support\Facades\Storage;
|
||||
class BackfillThumbnails extends Command
|
||||
{
|
||||
protected $signature = 'media:backfill-thumbnails {--limit=500}';
|
||||
|
||||
protected $description = 'Generate thumbnails for photos missing thumbnail_path or where thumbnail equals original.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$limit = (int) $this->option('limit');
|
||||
$rows = DB::table('photos')
|
||||
->select(['id','event_id','file_path','thumbnail_path'])
|
||||
->select(['id', 'event_id', 'file_path', 'thumbnail_path'])
|
||||
->orderBy('id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
$count = 0;
|
||||
foreach ($rows as $r) {
|
||||
$orig = $this->relativeFromUrl((string)$r->file_path);
|
||||
$thumb = (string)($r->thumbnail_path ?? '');
|
||||
if ($thumb && $thumb !== $r->file_path) continue; // already set to different thumb
|
||||
if (! $orig) continue;
|
||||
$orig = $this->relativeFromUrl((string) $r->file_path);
|
||||
$thumb = (string) ($r->thumbnail_path ?? '');
|
||||
if ($thumb && $thumb !== $r->file_path) {
|
||||
continue;
|
||||
} // already set to different thumb
|
||||
if (! $orig) {
|
||||
continue;
|
||||
}
|
||||
$baseName = pathinfo($orig, PATHINFO_FILENAME);
|
||||
$destRel = "events/{$r->event_id}/photos/thumbs/{$baseName}_thumb.jpg";
|
||||
$made = ImageHelper::makeThumbnailOnDisk('public', $orig, $destRel, 640, 82);
|
||||
@@ -39,6 +44,7 @@ class BackfillThumbnails extends Command
|
||||
}
|
||||
}
|
||||
$this->info("Done. Thumbnails generated: {$count}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
@@ -49,6 +55,7 @@ class BackfillThumbnails extends Command
|
||||
if (str_starts_with($p, '/storage/')) {
|
||||
return substr($p, strlen('/storage/'));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,15 @@ namespace App\Console\Commands;
|
||||
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MigrateLegacyPurchases extends Command
|
||||
{
|
||||
protected $signature = 'packages:migrate-legacy';
|
||||
|
||||
protected $description = 'Migrate legacy purchases to new system with temp tenants';
|
||||
|
||||
public function handle()
|
||||
@@ -21,19 +21,20 @@ class MigrateLegacyPurchases extends Command
|
||||
|
||||
if ($legacyPurchases->isEmpty()) {
|
||||
$this->info('No legacy purchases found.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info("Found {$legacyPurchases->count()} legacy purchases.");
|
||||
|
||||
foreach ($legacyPurchases as $purchase) {
|
||||
if (!$purchase->user_id) {
|
||||
if (! $purchase->user_id) {
|
||||
// Create temp user if no user
|
||||
$tempUser = User::create([
|
||||
'name' => 'Legacy User ' . $purchase->id,
|
||||
'email' => 'legacy' . $purchase->id . '@fotospiel.local',
|
||||
'name' => 'Legacy User '.$purchase->id,
|
||||
'email' => 'legacy'.$purchase->id.'@fotospiel.local',
|
||||
'password' => Hash::make('legacy'),
|
||||
'username' => 'legacy' . $purchase->id,
|
||||
'username' => 'legacy'.$purchase->id,
|
||||
'first_name' => 'Legacy',
|
||||
'last_name' => 'User',
|
||||
'address' => 'Legacy Address',
|
||||
@@ -43,7 +44,7 @@ class MigrateLegacyPurchases extends Command
|
||||
|
||||
$tempTenant = Tenant::create([
|
||||
'user_id' => $tempUser->id,
|
||||
'name' => 'Legacy Tenant ' . $purchase->id,
|
||||
'name' => 'Legacy Tenant '.$purchase->id,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
@@ -73,6 +74,7 @@ class MigrateLegacyPurchases extends Command
|
||||
}
|
||||
|
||||
$this->info('Legacy migration completed.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,12 @@ class MonitorStorageCommand extends Command
|
||||
|
||||
$assetStats = $this->buildAssetStatistics();
|
||||
$thresholds = $this->capacityThresholds();
|
||||
$checksumConfig = $this->checksumAlertConfig();
|
||||
$checksumWindowMinutes = $checksumConfig['window_minutes'];
|
||||
$checksumThresholds = $checksumConfig['thresholds'];
|
||||
$checksumMismatches = $checksumConfig['enabled'] && $checksumWindowMinutes > 0
|
||||
? $this->checksumMismatchCounts($checksumWindowMinutes)
|
||||
: [];
|
||||
$alerts = [];
|
||||
$snapshotTargets = [];
|
||||
|
||||
@@ -78,6 +84,7 @@ class MonitorStorageCommand extends Command
|
||||
];
|
||||
}
|
||||
|
||||
$targetChecksumMismatches = $checksumMismatches[$target->id] ?? 0;
|
||||
$snapshotTargets[] = [
|
||||
'id' => $target->id,
|
||||
'key' => $target->key,
|
||||
@@ -85,13 +92,35 @@ class MonitorStorageCommand extends Command
|
||||
'is_hot' => (bool) $target->is_hot,
|
||||
'capacity' => $capacity,
|
||||
'assets' => $assets,
|
||||
'checksum_mismatches' => [
|
||||
'count' => $targetChecksumMismatches,
|
||||
'window_minutes' => $checksumWindowMinutes,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if ($checksumConfig['enabled'] && $checksumWindowMinutes > 0) {
|
||||
$totalMismatches = array_sum($checksumMismatches);
|
||||
$checksumSeverity = $this->determineChecksumSeverity($totalMismatches, $checksumThresholds);
|
||||
|
||||
if ($checksumSeverity !== 'ok') {
|
||||
$alerts[] = [
|
||||
'type' => 'checksum_mismatch',
|
||||
'severity' => $checksumSeverity,
|
||||
'count' => $totalMismatches,
|
||||
'window_minutes' => $checksumWindowMinutes,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$snapshot = [
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'targets' => $snapshotTargets,
|
||||
'alerts' => $alerts,
|
||||
'checksum' => [
|
||||
'window_minutes' => $checksumWindowMinutes,
|
||||
'mismatch_total' => array_sum($checksumMismatches),
|
||||
],
|
||||
];
|
||||
|
||||
$ttlMinutes = max(1, (int) config('storage-monitor.monitor.cache_minutes', 15));
|
||||
@@ -191,4 +220,62 @@ class MonitorStorageCommand extends Command
|
||||
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
private function checksumAlertConfig(): array
|
||||
{
|
||||
$enabled = (bool) config('storage-monitor.checksum_validation.enabled', true);
|
||||
$windowMinutes = max(0, (int) config('storage-monitor.checksum_validation.alert_window_minutes', 60));
|
||||
$warning = (int) config('storage-monitor.checksum_validation.thresholds.warning', 1);
|
||||
$critical = (int) config('storage-monitor.checksum_validation.thresholds.critical', 5);
|
||||
|
||||
if ($warning > $critical && $critical > 0) {
|
||||
[$warning, $critical] = [$critical, $warning];
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
'window_minutes' => $windowMinutes,
|
||||
'thresholds' => [
|
||||
'warning' => $warning,
|
||||
'critical' => $critical,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function checksumMismatchCounts(int $windowMinutes): array
|
||||
{
|
||||
$query = EventMediaAsset::query()
|
||||
->selectRaw('media_storage_target_id, COUNT(*) as total_count')
|
||||
->where('status', 'failed')
|
||||
->where('meta->checksum_status', 'mismatch');
|
||||
|
||||
if ($windowMinutes > 0) {
|
||||
$query->where('updated_at', '>=', now()->subMinutes($windowMinutes));
|
||||
}
|
||||
|
||||
return $query->groupBy('media_storage_target_id')
|
||||
->get()
|
||||
->mapWithKeys(fn ($row) => [(int) $row->media_storage_target_id => (int) $row->total_count])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function determineChecksumSeverity(int $count, array $thresholds): string
|
||||
{
|
||||
$warning = (int) ($thresholds['warning'] ?? 1);
|
||||
$critical = (int) ($thresholds['critical'] ?? 5);
|
||||
|
||||
if ($count <= 0) {
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
if ($critical > 0 && $count >= $critical) {
|
||||
return 'critical';
|
||||
}
|
||||
|
||||
if ($warning > 0 && $count >= $warning) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'ok';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ class SendAbandonedCheckoutReminders extends Command
|
||||
if ($this->shouldSendReminder($checkout, $stage)) {
|
||||
$resumeUrl = $this->generateResumeUrl($checkout);
|
||||
|
||||
if (!$isDryRun) {
|
||||
if (! $isDryRun) {
|
||||
$mailLocale = $checkout->user->preferred_locale ?? config('app.locale');
|
||||
|
||||
Mail::to($checkout->user)
|
||||
@@ -86,8 +86,8 @@ class SendAbandonedCheckoutReminders extends Command
|
||||
$totalProcessed++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Log::error("Failed to send {$stage} reminder for checkout {$checkout->id}: " . $e->getMessage());
|
||||
$this->error(" ❌ Failed to process checkout {$checkout->id}: " . $e->getMessage());
|
||||
Log::error("Failed to send {$stage} reminder for checkout {$checkout->id}: ".$e->getMessage());
|
||||
$this->error(" ❌ Failed to process checkout {$checkout->id}: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,7 @@ class SendAbandonedCheckoutReminders extends Command
|
||||
->count();
|
||||
|
||||
if ($oldCheckouts > 0) {
|
||||
if (!$isDryRun) {
|
||||
if (! $isDryRun) {
|
||||
AbandonedCheckoutModel::where('abandoned_at', '<', now()->subDays(30))
|
||||
->where('converted', false)
|
||||
->delete();
|
||||
@@ -108,10 +108,10 @@ class SendAbandonedCheckoutReminders extends Command
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("✅ Reminder process completed!");
|
||||
$this->info('✅ Reminder process completed!');
|
||||
$this->info(" Processed: {$totalProcessed} checkouts");
|
||||
|
||||
if (!$isDryRun) {
|
||||
if (! $isDryRun) {
|
||||
$this->info(" Sent: {$totalSent} reminder emails");
|
||||
} else {
|
||||
$this->info(" Would send: {$totalSent} reminder emails");
|
||||
@@ -131,12 +131,12 @@ class SendAbandonedCheckoutReminders extends Command
|
||||
}
|
||||
|
||||
// User existiert noch?
|
||||
if (!$checkout->user) {
|
||||
if (! $checkout->user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Package existiert noch?
|
||||
if (!$checkout->package) {
|
||||
if (! $checkout->package) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ use Illuminate\Support\Str;
|
||||
|
||||
class SyncGoogleFonts extends Command
|
||||
{
|
||||
protected $signature = 'fonts:sync-google {--count=50 : Number of popular fonts to fetch} {--weights=400,700 : Comma separated numeric font weights to download} {--italic : Also download italic variants where available} {--force : Re-download files even if they exist} {--path= : Optional custom output directory (defaults to public/fonts/google)} {--family= : Download specific family name(s), comma separated (case-insensitive)} {--category= : Filter by category, comma separated (e.g. sans-serif,serif)} {--prune : Remove local font families not included in this sync} {--dry-run : Show what would be downloaded without writing files}';
|
||||
protected $signature = 'fonts:sync-google {--count=50 : Number of popular fonts to fetch} {--weights=400,700 : Comma separated numeric font weights to download} {--italic : Also download italic variants where available} {--force : Re-download files even if they exist} {--path= : Optional custom output directory (defaults to public/fonts/google)} {--family= : Download specific family name(s), comma separated (case-insensitive)} {--category= : Filter by category, comma separated (e.g. sans-serif,serif)} {--prune : Remove local font families not included in this sync} {--dry-run : Show what would be downloaded without writing files} {--from-disk : Rebuild manifest + CSS from existing font files without downloading}';
|
||||
|
||||
protected $description = 'Download the most popular Google Fonts to the local public/fonts/google directory and generate a manifest + CSS file.';
|
||||
|
||||
@@ -20,6 +20,17 @@ class SyncGoogleFonts extends Command
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$fromDisk = (bool) $this->option('from-disk');
|
||||
$pathOption = $this->option('path');
|
||||
$basePath = $pathOption
|
||||
? (Str::startsWith($pathOption, DIRECTORY_SEPARATOR) ? $pathOption : base_path($pathOption))
|
||||
: public_path('fonts/google');
|
||||
|
||||
if ($fromDisk) {
|
||||
return $this->syncFromDisk($basePath, $dryRun);
|
||||
}
|
||||
|
||||
$apiKey = config('services.google_fonts.key');
|
||||
|
||||
if (! $apiKey) {
|
||||
@@ -32,16 +43,10 @@ class SyncGoogleFonts extends Command
|
||||
$weights = $this->prepareWeights($this->option('weights'));
|
||||
$includeItalic = (bool) $this->option('italic');
|
||||
$force = (bool) $this->option('force');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$families = $this->normalizeFamilyOption($this->option('family'));
|
||||
$categories = $this->prepareCategories($this->option('category'));
|
||||
$prune = (bool) $this->option('prune');
|
||||
|
||||
$pathOption = $this->option('path');
|
||||
$basePath = $pathOption
|
||||
? (Str::startsWith($pathOption, DIRECTORY_SEPARATOR) ? $pathOption : base_path($pathOption))
|
||||
: public_path('fonts/google');
|
||||
|
||||
if (count($families)) {
|
||||
$label = count($families) > 1 ? 'families' : 'family';
|
||||
$this->info(sprintf('Fetching Google Font %s "%s" (weights: %s, italic: %s)...', $label, implode(', ', $families), implode(', ', $weights), $includeItalic ? 'yes' : 'no'));
|
||||
@@ -206,6 +211,204 @@ class SyncGoogleFonts extends Command
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function syncFromDisk(string $basePath, bool $dryRun): int
|
||||
{
|
||||
if (! File::isDirectory($basePath)) {
|
||||
$this->error(sprintf('Font directory not found: %s', $basePath));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($this->option('prune')) {
|
||||
$this->warn('Ignoring --prune when rebuilding from disk.');
|
||||
}
|
||||
|
||||
$fonts = $this->buildManifestFromDisk($basePath);
|
||||
|
||||
if (! count($fonts)) {
|
||||
$this->warn('No fonts found on disk.');
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info(sprintf('Dry run complete: %d font families would be written to %s', count($fonts), $basePath));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->writeManifest($basePath, $fonts);
|
||||
$this->writeCss($basePath, $fonts);
|
||||
Cache::forget('fonts:manifest');
|
||||
|
||||
$this->info(sprintf('Rebuilt manifest for %d font families from %s', count($fonts), $basePath));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function buildManifestFromDisk(string $basePath): array
|
||||
{
|
||||
$directories = File::directories($basePath);
|
||||
$fonts = [];
|
||||
|
||||
foreach ($directories as $dir) {
|
||||
$slug = basename($dir);
|
||||
$files = collect(File::files($dir))
|
||||
->filter(function (\SplFileInfo $file) {
|
||||
$extension = strtolower($file->getExtension());
|
||||
|
||||
return in_array($extension, ['woff2', 'woff', 'otf', 'ttf'], true);
|
||||
})
|
||||
->values();
|
||||
|
||||
if (! $files->count()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$variantsByKey = [];
|
||||
foreach ($files as $file) {
|
||||
$filename = $file->getFilename();
|
||||
$extension = strtolower($file->getExtension());
|
||||
$style = $this->extractStyleFromFilename($filename);
|
||||
$weight = $this->extractWeightFromFilename($filename);
|
||||
$variantKey = $this->buildVariantKey($weight, $style);
|
||||
$priority = $this->extensionPriority($extension);
|
||||
$relativePath = sprintf('/fonts/google/%s/%s', $slug, $filename);
|
||||
|
||||
$existing = $variantsByKey[$variantKey] ?? null;
|
||||
if ($existing && ($existing['priority'] ?? 0) >= $priority) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$variantsByKey[$variantKey] = [
|
||||
'variant' => $variantKey,
|
||||
'weight' => $weight,
|
||||
'style' => $style,
|
||||
'url' => $relativePath,
|
||||
'priority' => $priority,
|
||||
];
|
||||
}
|
||||
|
||||
if (! count($variantsByKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$variants = array_values(array_map(function (array $variant) {
|
||||
unset($variant['priority']);
|
||||
|
||||
return $variant;
|
||||
}, $variantsByKey));
|
||||
|
||||
usort($variants, function (array $left, array $right) {
|
||||
$weightCompare = ($left['weight'] ?? 400) <=> ($right['weight'] ?? 400);
|
||||
if ($weightCompare !== 0) {
|
||||
return $weightCompare;
|
||||
}
|
||||
|
||||
return strcmp((string) ($left['style'] ?? 'normal'), (string) ($right['style'] ?? 'normal'));
|
||||
});
|
||||
|
||||
$fonts[] = [
|
||||
'family' => $this->familyFromSlug($slug),
|
||||
'slug' => $slug,
|
||||
'category' => null,
|
||||
'variants' => $variants,
|
||||
];
|
||||
}
|
||||
|
||||
usort($fonts, fn (array $left, array $right) => strcmp((string) $left['family'], (string) $right['family']));
|
||||
|
||||
return $fonts;
|
||||
}
|
||||
|
||||
private function familyFromSlug(string $slug): string
|
||||
{
|
||||
$parts = array_filter(explode('-', $slug), fn ($part) => $part !== '');
|
||||
|
||||
$words = array_map(function (string $part) {
|
||||
if (is_numeric($part)) {
|
||||
return $part;
|
||||
}
|
||||
|
||||
if (strlen($part) <= 3) {
|
||||
return strtoupper($part);
|
||||
}
|
||||
|
||||
return ucfirst(strtolower($part));
|
||||
}, $parts);
|
||||
|
||||
return trim(implode(' ', $words));
|
||||
}
|
||||
|
||||
private function extractStyleFromFilename(string $filename): string
|
||||
{
|
||||
$lower = strtolower($filename);
|
||||
|
||||
return str_contains($lower, 'italic') || str_contains($lower, 'oblique') ? 'italic' : 'normal';
|
||||
}
|
||||
|
||||
private function extractWeightFromFilename(string $filename): int
|
||||
{
|
||||
if (preg_match('/(?:^|[^0-9])(100|200|300|400|500|600|700|800|900)(?:[^0-9]|$)/', $filename, $matches)) {
|
||||
return (int) $matches[1];
|
||||
}
|
||||
|
||||
$lower = strtolower($filename);
|
||||
$weightMap = [
|
||||
'thin' => 100,
|
||||
'extralight' => 200,
|
||||
'ultralight' => 200,
|
||||
'light' => 300,
|
||||
'regular' => 400,
|
||||
'book' => 400,
|
||||
'medium' => 500,
|
||||
'semibold' => 600,
|
||||
'demibold' => 600,
|
||||
'bold' => 700,
|
||||
'extrabold' => 800,
|
||||
'ultrabold' => 800,
|
||||
'black' => 900,
|
||||
'heavy' => 900,
|
||||
];
|
||||
|
||||
foreach ($weightMap as $label => $weight) {
|
||||
if (str_contains($lower, $label)) {
|
||||
return $weight;
|
||||
}
|
||||
}
|
||||
|
||||
return 400;
|
||||
}
|
||||
|
||||
private function buildVariantKey(int $weight, string $style): string
|
||||
{
|
||||
if ($weight === 400 && $style === 'normal') {
|
||||
return 'regular';
|
||||
}
|
||||
|
||||
if ($weight === 400 && $style === 'italic') {
|
||||
return 'italic';
|
||||
}
|
||||
|
||||
if ($style === 'italic') {
|
||||
return $weight.'italic';
|
||||
}
|
||||
|
||||
return (string) $weight;
|
||||
}
|
||||
|
||||
private function extensionPriority(string $extension): int
|
||||
{
|
||||
return match ($extension) {
|
||||
'woff2' => 4,
|
||||
'woff' => 3,
|
||||
'otf' => 2,
|
||||
'ttf' => 1,
|
||||
default => 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
|
||||
@@ -6,4 +6,4 @@ enum PackageType: string
|
||||
{
|
||||
case ENDCUSTOMER = 'endcustomer';
|
||||
case RESELLER = 'reseller';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ namespace App\Exports;
|
||||
use App\Models\EventPurchase;
|
||||
use Filament\Actions\Exports\Exporter;
|
||||
use Filament\Actions\Exports\Models\Export;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class EventPurchaseExporter extends Exporter
|
||||
{
|
||||
@@ -28,11 +26,10 @@ class EventPurchaseExporter extends Exporter
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
public static function getCompletedNotificationBody(Export $export): string
|
||||
{
|
||||
$body = "Your Event Purchases export has completed and is ready for download. {$export->successful_rows} purchases were exported.";
|
||||
|
||||
return $body;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,4 +16,4 @@ class ListCategories extends ListRecords
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,9 +79,10 @@ class PostResource extends Resource
|
||||
->label('Inhalt')
|
||||
->required()
|
||||
->columnSpanFull(),
|
||||
TextInput::make('excerpt.de')
|
||||
Textarea::make('excerpt.de')
|
||||
->label('Auszug')
|
||||
->maxLength(255),
|
||||
->maxLength(65535)
|
||||
->columnSpanFull(),
|
||||
TextInput::make('meta_title.de')
|
||||
->label('Meta-Titel')
|
||||
->maxLength(255),
|
||||
@@ -99,9 +100,10 @@ class PostResource extends Resource
|
||||
MarkdownEditor::make('content.en')
|
||||
->label('Inhalt')
|
||||
->columnSpanFull(),
|
||||
TextInput::make('excerpt.en')
|
||||
Textarea::make('excerpt.en')
|
||||
->label('Auszug')
|
||||
->maxLength(255),
|
||||
->maxLength(65535)
|
||||
->columnSpanFull(),
|
||||
TextInput::make('meta_title.en')
|
||||
->label('Meta-Titel')
|
||||
->maxLength(255),
|
||||
@@ -121,9 +123,10 @@ class PostResource extends Resource
|
||||
->unique(BlogPost::class, 'slug', ignoreRecord: true)
|
||||
->maxLength(255)
|
||||
->columnSpanFull(),
|
||||
FileUpload::make('featured_image')
|
||||
FileUpload::make('banner')
|
||||
->label('Featured Image')
|
||||
->image()
|
||||
->disk('public')
|
||||
->directory('blog')
|
||||
->visibility('public'),
|
||||
Select::make('blog_category_id')
|
||||
|
||||
@@ -16,4 +16,4 @@ class ListPosts extends ListRecords
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,4 @@ use Filament\Resources\Pages\ViewRecord;
|
||||
class ViewPost extends ViewRecord
|
||||
{
|
||||
protected static string $resource = PostResource::class;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,4 +26,4 @@ trait HasContentEditor
|
||||
'h3',
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ use Illuminate\Support\Facades\Storage;
|
||||
class ImportEmotions extends Page
|
||||
{
|
||||
protected static string $resource = EmotionResource::class;
|
||||
|
||||
protected string $view = 'filament.resources.emotion-resource.pages.import-emotions';
|
||||
|
||||
protected ?string $heading = null;
|
||||
|
||||
public ?string $file = null;
|
||||
@@ -36,6 +38,7 @@ class ImportEmotions extends Page
|
||||
$path = $this->form->getState()['file'] ?? null;
|
||||
if (! $path || ! Storage::disk('public')->exists($path)) {
|
||||
Notification::make()->danger()->title(__('admin.notifications.file_not_found'))->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,4 +16,4 @@ class ListEventPurchases extends ListRecords
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,19 +60,32 @@ class EventResource extends Resource
|
||||
->required()
|
||||
->unique(ignoreRecord: true)
|
||||
->maxLength(255),
|
||||
TextInput::make('join_link_display')
|
||||
->label(__('admin.events.fields.join_link'))
|
||||
->afterStateHydrated(function (TextInput $component, ?Event $record) {
|
||||
if (! $record) {
|
||||
return;
|
||||
}
|
||||
$token = $record->joinTokens()->latest()->first();
|
||||
$component->state($token ? url('/e/'.$token->token) : '-');
|
||||
})
|
||||
->readOnly()
|
||||
->dehydrated(false)
|
||||
->visibleOn('edit'),
|
||||
DatePicker::make('date')
|
||||
->label(__('admin.events.fields.date'))
|
||||
->required(),
|
||||
Select::make('event_type_id')
|
||||
->label(__('admin.events.fields.type'))
|
||||
->options(EventType::query()->pluck('name', 'id'))
|
||||
->options(fn () => EventType::all()->pluck('name.de', 'id'))
|
||||
->searchable(),
|
||||
Select::make('package_id')
|
||||
->label(__('admin.events.fields.package'))
|
||||
->options(\App\Models\Package::query()->where('type', 'endcustomer')->pluck('name', 'id'))
|
||||
->searchable()
|
||||
->preload()
|
||||
->required(),
|
||||
->required()
|
||||
->visibleOn('create'),
|
||||
TextInput::make('default_locale')
|
||||
->label(__('admin.events.fields.default_locale'))
|
||||
->default('de')
|
||||
@@ -96,13 +109,13 @@ class EventResource extends Resource
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('id')->sortable(),
|
||||
Tables\Columns\TextColumn::make('tenant.name')->label(__('admin.events.table.tenant'))->searchable(),
|
||||
Tables\Columns\TextColumn::make('name.de')
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->label(__('admin.events.fields.name'))
|
||||
->formatStateUsing(fn (mixed $state): string => static::formatEventName($state))
|
||||
->limit(30),
|
||||
Tables\Columns\TextColumn::make('slug')->searchable(),
|
||||
Tables\Columns\TextColumn::make('date')->date(),
|
||||
Tables\Columns\IconColumn::make('is_active')->boolean(),
|
||||
Tables\Columns\TextColumn::make('default_locale'),
|
||||
Tables\Columns\TextColumn::make('eventPackage.package.name')
|
||||
->label(__('admin.events.table.package'))
|
||||
->badge()
|
||||
@@ -115,22 +128,6 @@ class EventResource extends Resource
|
||||
->badge()
|
||||
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
|
||||
->getStateUsing(fn ($record) => $record->eventPackage?->remaining_photos ?? 0),
|
||||
Tables\Columns\TextColumn::make('primary_join_token')
|
||||
->label(__('admin.events.table.join'))
|
||||
->getStateUsing(function ($record) {
|
||||
$token = $record->joinTokens()->latest()->first();
|
||||
|
||||
return $token ? url('/e/'.$token->token) : __('admin.events.table.no_join_tokens');
|
||||
})
|
||||
->description(function ($record) {
|
||||
$total = $record->joinTokens()->count();
|
||||
|
||||
return $total > 0
|
||||
? __('admin.events.table.join_tokens_total', ['count' => $total])
|
||||
: __('admin.events.table.join_tokens_missing');
|
||||
})
|
||||
->copyable()
|
||||
->copyMessage(__('admin.events.messages.join_link_copied')),
|
||||
Tables\Columns\TextColumn::make('created_at')->since(),
|
||||
])
|
||||
->filters([])
|
||||
@@ -282,6 +279,30 @@ class EventResource extends Resource
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|string|null $name
|
||||
*/
|
||||
private static function formatEventName(mixed $name): string
|
||||
{
|
||||
if (is_array($name)) {
|
||||
$candidates = [
|
||||
$name['de'] ?? null,
|
||||
$name['en'] ?? null,
|
||||
reset($name) ?: null,
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (is_string($candidate) && $candidate !== '') {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
return is_string($name) ? $name : '';
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -8,4 +8,25 @@ use App\Filament\Resources\Pages\AuditedCreateRecord;
|
||||
class CreateEvent extends AuditedCreateRecord
|
||||
{
|
||||
protected static string $resource = EventResource::class;
|
||||
|
||||
public ?int $packageId = null;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$this->packageId = $data['package_id'] ?? null;
|
||||
unset($data['package_id']);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
if ($this->packageId) {
|
||||
$this->record->eventPackages()->create([
|
||||
'package_id' => $this->packageId,
|
||||
]);
|
||||
}
|
||||
|
||||
parent::afterCreate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
|
||||
class EventPackagesRelationManager extends RelationManager
|
||||
{
|
||||
@@ -59,6 +58,7 @@ class EventPackagesRelationManager extends RelationManager
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with('package'))
|
||||
->recordTitleAttribute('package.name')
|
||||
->columns([
|
||||
TextColumn::make('package.name')
|
||||
@@ -147,9 +147,4 @@ class EventPackagesRelationManager extends RelationManager
|
||||
{
|
||||
return __('admin.events.relation_managers.event_packages.title');
|
||||
}
|
||||
|
||||
public function getTableQuery(): Builder|Relation
|
||||
{
|
||||
return parent::getTableQuery()->with('package');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,18 +113,64 @@ class EventTypeResource extends Resource
|
||||
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||
static::class
|
||||
)),
|
||||
Actions\DeleteAction::make()
|
||||
->action(function (EventType $record, Actions\DeleteAction $action) {
|
||||
try {
|
||||
$record->delete();
|
||||
} catch (\Exception $e) {
|
||||
$isConstraint = ($e instanceof \Illuminate\Database\QueryException && ($e->getCode() == 23000 || ($e->errorInfo[0] ?? '') == 23000));
|
||||
|
||||
if ($isConstraint) {
|
||||
\Filament\Notifications\Notification::make()
|
||||
->title(__('admin.common.error'))
|
||||
->body(__('admin.event_types.messages.delete_constraint_error'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
$action->halt();
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
})
|
||||
->after(fn (EventType $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'deleted',
|
||||
$record,
|
||||
source: static::class
|
||||
)),
|
||||
])
|
||||
->bulkActions([
|
||||
Actions\DeleteBulkAction::make()
|
||||
->after(function (Collection $records): void {
|
||||
->action(function (Collection $records, Actions\DeleteBulkAction $action) {
|
||||
$logger = app(SuperAdminAuditLogger::class);
|
||||
$deletedCount = 0;
|
||||
$failedCount = 0;
|
||||
|
||||
foreach ($records as $record) {
|
||||
$logger->recordModelMutation(
|
||||
'deleted',
|
||||
$record,
|
||||
source: static::class
|
||||
);
|
||||
try {
|
||||
$record->delete();
|
||||
$logger->recordModelMutation('deleted', $record, source: static::class);
|
||||
$deletedCount++;
|
||||
} catch (\Exception $e) {
|
||||
$isConstraint = ($e instanceof \Illuminate\Database\QueryException && ($e->getCode() == 23000 || ($e->errorInfo[0] ?? '') == 23000));
|
||||
if ($isConstraint) {
|
||||
$failedCount++;
|
||||
} else {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($failedCount > 0) {
|
||||
\Filament\Notifications\Notification::make()
|
||||
->title(__('admin.common.error'))
|
||||
->body(__('admin.event_types.messages.delete_constraint_error')." ($failedCount failed, $deletedCount deleted)")
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
if ($deletedCount === 0) {
|
||||
$action->halt();
|
||||
}
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -17,4 +17,3 @@ class ListMediaStorageTargets extends ListRecords
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,4 +16,4 @@ class ListPackages extends ListRecords
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,3 @@ class ListPurchaseHistories extends ListRecords
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,4 +14,3 @@ class ViewPurchaseHistory extends ViewRecord
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,4 +16,4 @@ class ListPurchases extends ListRecords
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Filament\Resources\TenantResource\RelationManagers\PackagePurchasesRelat
|
||||
use App\Filament\Resources\TenantResource\RelationManagers\TenantPackagesRelationManager;
|
||||
use App\Filament\Resources\TenantResource\Schemas\TenantInfolist;
|
||||
use App\Jobs\AnonymizeAccount;
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use App\Notifications\InactiveTenantDeletionWarning;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
@@ -205,11 +206,13 @@ class TenantResource extends Resource
|
||||
Forms\Components\Textarea::make('reason')->label('Grund')->rows(3),
|
||||
])
|
||||
->action(function (Tenant $record, array $data) {
|
||||
$package = Package::query()->find($data['package_id']);
|
||||
\App\Models\TenantPackage::create([
|
||||
'tenant_id' => $record->id,
|
||||
'package_id' => $data['package_id'],
|
||||
'expires_at' => $data['expires_at'],
|
||||
'active' => true,
|
||||
'price' => $package?->price ?? 0,
|
||||
'reason' => $data['reason'] ?? null,
|
||||
]);
|
||||
\App\Models\PackagePurchase::create([
|
||||
|
||||
@@ -3,39 +3,78 @@
|
||||
namespace App\Filament\SuperAdmin\Pages\Auth;
|
||||
|
||||
use Filament\Auth\Pages\EditProfile as BaseEditProfile;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Components\Component;
|
||||
use Filament\Schemas\Components\Livewire;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class EditProfile extends BaseEditProfile
|
||||
{
|
||||
public function mount(): void
|
||||
protected function getPasswordConfirmationFormComponent(): Component
|
||||
{
|
||||
Log::info('EditProfile class loaded for superadmin');
|
||||
parent::mount();
|
||||
return TextInput::make('passwordConfirmation')
|
||||
->label(__('filament-panels::auth/pages/edit-profile.form.password_confirmation.label'))
|
||||
->validationAttribute(__('filament-panels::auth/pages/edit-profile.form.password_confirmation.validation_attribute'))
|
||||
->password()
|
||||
->autocomplete('new-password')
|
||||
->revealable(filament()->arePasswordsRevealable())
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => filled($get('password')))
|
||||
->dehydrated(false);
|
||||
}
|
||||
|
||||
protected function getCurrentPasswordFormComponent(): Component
|
||||
{
|
||||
return TextInput::make('currentPassword')
|
||||
->label(__('filament-panels::auth/pages/edit-profile.form.current_password.label'))
|
||||
->validationAttribute(__('filament-panels::auth/pages/edit-profile.form.current_password.validation_attribute'))
|
||||
->belowContent(__('filament-panels::auth/pages/edit-profile.form.current_password.below_content'))
|
||||
->password()
|
||||
->autocomplete('current-password')
|
||||
->currentPassword(guard: Filament::getAuthGuard())
|
||||
->revealable(filament()->arePasswordsRevealable())
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => filled($get('password')))
|
||||
->dehydrated(false);
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
$this->getNameFormComponent(),
|
||||
$this->getEmailFormComponent(),
|
||||
TextInput::make('username')
|
||||
->required()
|
||||
->unique(ignoreRecord: true)
|
||||
->maxLength(255),
|
||||
Select::make('preferred_locale')
|
||||
->options([
|
||||
'de' => 'Deutsch',
|
||||
'en' => 'English',
|
||||
Section::make('Profile')
|
||||
->schema([
|
||||
$this->getNameFormComponent(),
|
||||
$this->getEmailFormComponent(),
|
||||
TextInput::make('username')
|
||||
->required()
|
||||
->unique(ignoreRecord: true)
|
||||
->maxLength(255),
|
||||
Select::make('preferred_locale')
|
||||
->options([
|
||||
'de' => 'Deutsch',
|
||||
'en' => 'English',
|
||||
])
|
||||
->default('de')
|
||||
->required(),
|
||||
])
|
||||
->default('de')
|
||||
->required(),
|
||||
$this->getPasswordFormComponent(),
|
||||
$this->getPasswordConfirmationFormComponent(),
|
||||
$this->getCurrentPasswordFormComponent(),
|
||||
->columns(2),
|
||||
Section::make('Security')
|
||||
->schema([
|
||||
$this->getPasswordFormComponent(),
|
||||
$this->getPasswordConfirmationFormComponent(),
|
||||
$this->getCurrentPasswordFormComponent(),
|
||||
])
|
||||
->columns(1),
|
||||
Section::make('Support API Tokens')
|
||||
->description('Manage bearer tokens for external support tooling.')
|
||||
->schema([
|
||||
Livewire::make('support-api-token-manager'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
280
app/Filament/SuperAdmin/Widgets/SupportApiTokenManager.php
Normal file
280
app/Filament/SuperAdmin/Widgets/SupportApiTokenManager.php
Normal file
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\SuperAdmin\Widgets;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\CheckboxList;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Widgets\TableWidget;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Laravel\Sanctum\NewAccessToken;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
|
||||
class SupportApiTokenManager extends TableWidget
|
||||
{
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->heading('Support API Tokens')
|
||||
->query(fn (): Builder => $this->getTokenQuery())
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->label('Name')
|
||||
->sortable()
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('abilities')
|
||||
->label('Abilities')
|
||||
->formatStateUsing(fn ($state): string => $this->formatAbilities($state))
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('last_used_at')
|
||||
->label('Last used')
|
||||
->since()
|
||||
->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('expires_at')
|
||||
->label('Expires')
|
||||
->dateTime('Y-m-d H:i')
|
||||
->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label('Created')
|
||||
->since(),
|
||||
])
|
||||
->headerActions([
|
||||
Action::make('create_support_token')
|
||||
->label('Create token')
|
||||
->icon('heroicon-o-key')
|
||||
->form([
|
||||
TextInput::make('name')
|
||||
->label('Token name')
|
||||
->default($this->defaultTokenName())
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText('Existing tokens with the same name will be revoked.'),
|
||||
CheckboxList::make('abilities')
|
||||
->label('Abilities')
|
||||
->options($this->abilityOptions())
|
||||
->columns(2)
|
||||
->required()
|
||||
->default($this->defaultAbilities()),
|
||||
DateTimePicker::make('expires_at')
|
||||
->label('Expires at')
|
||||
->displayFormat('Y-m-d H:i')
|
||||
->seconds(false),
|
||||
])
|
||||
->action(function (array $data): void {
|
||||
$user = $this->getUser();
|
||||
|
||||
if (! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$name = $this->normalizeTokenName($data['name'] ?? null);
|
||||
$abilities = $this->normalizeAbilities($data['abilities'] ?? []);
|
||||
$expiresAt = $this->normalizeExpiresAt($data['expires_at'] ?? null);
|
||||
|
||||
$user->tokens()->where('name', $name)->delete();
|
||||
|
||||
$token = $user->createToken($name, $abilities, $expiresAt);
|
||||
|
||||
$this->recordTokenCreated($token, $abilities, $user);
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Token created')
|
||||
->body('Copy this token now. It will not be shown again: '.$token->plainTextToken)
|
||||
->persistent()
|
||||
->send();
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
Action::make('revoke')
|
||||
->label('Revoke')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (PersonalAccessToken $record): bool => $this->ownsToken($record))
|
||||
->action(function (PersonalAccessToken $record): void {
|
||||
if (! $this->ownsToken($record)) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'support-api-token.revoked',
|
||||
$record,
|
||||
['fields' => ['name', 'abilities', 'expires_at']],
|
||||
actor: $this->getUser(),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
$record->delete();
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Token revoked')
|
||||
->send();
|
||||
}),
|
||||
])
|
||||
->emptyStateHeading('No support API tokens')
|
||||
->emptyStateDescription('Create a token for external support tooling.');
|
||||
}
|
||||
|
||||
private function getTokenQuery(): Builder
|
||||
{
|
||||
$user = $this->getUser();
|
||||
|
||||
if (! $user) {
|
||||
return PersonalAccessToken::query()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return PersonalAccessToken::query()
|
||||
->where('tokenable_id', $user->getKey())
|
||||
->where('tokenable_type', $user->getMorphClass());
|
||||
}
|
||||
|
||||
private function getUser(): ?User
|
||||
{
|
||||
$user = Filament::auth()->user();
|
||||
|
||||
return $user instanceof User ? $user : null;
|
||||
}
|
||||
|
||||
private function formatAbilities(mixed $state): string
|
||||
{
|
||||
if (is_array($state)) {
|
||||
return implode(', ', $state);
|
||||
}
|
||||
|
||||
if (is_string($state)) {
|
||||
return $state;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function defaultAbilities(): array
|
||||
{
|
||||
$abilities = config('support-api.token.default_abilities', []);
|
||||
|
||||
if (! is_array($abilities)) {
|
||||
return ['support-admin'];
|
||||
}
|
||||
|
||||
$abilities = array_values(array_filter($abilities, fn ($ability) => is_string($ability) && $ability !== ''));
|
||||
|
||||
if (! in_array('support-admin', $abilities, true)) {
|
||||
$abilities[] = 'support-admin';
|
||||
}
|
||||
|
||||
return array_values(array_unique($abilities));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function abilityOptions(): array
|
||||
{
|
||||
$options = [];
|
||||
|
||||
foreach ($this->defaultAbilities() as $ability) {
|
||||
$options[$ability] = $ability;
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $abilities
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizeAbilities(array $abilities): array
|
||||
{
|
||||
$allowed = $this->defaultAbilities();
|
||||
$filtered = array_values(array_intersect($abilities, $allowed));
|
||||
|
||||
if (! in_array('support-admin', $filtered, true)) {
|
||||
$filtered[] = 'support-admin';
|
||||
}
|
||||
|
||||
sort($filtered);
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
private function defaultTokenName(): string
|
||||
{
|
||||
$name = config('support-api.token.name');
|
||||
|
||||
if (is_string($name) && $name !== '') {
|
||||
return $name;
|
||||
}
|
||||
|
||||
return 'support-api';
|
||||
}
|
||||
|
||||
private function normalizeTokenName(?string $name): string
|
||||
{
|
||||
$name = $name ? trim($name) : '';
|
||||
|
||||
return $name !== '' ? $name : $this->defaultTokenName();
|
||||
}
|
||||
|
||||
private function normalizeExpiresAt(mixed $expiresAt): ?Carbon
|
||||
{
|
||||
if ($expiresAt instanceof Carbon) {
|
||||
return $expiresAt;
|
||||
}
|
||||
|
||||
if ($expiresAt instanceof \DateTimeInterface) {
|
||||
return Carbon::instance($expiresAt);
|
||||
}
|
||||
|
||||
if (is_string($expiresAt) && $expiresAt !== '') {
|
||||
return Carbon::parse($expiresAt);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function recordTokenCreated(NewAccessToken $token, array $abilities, User $user): void
|
||||
{
|
||||
$actionLog = app(SuperAdminAuditLogger::class);
|
||||
|
||||
$actionLog->record(
|
||||
'support-api-token.created',
|
||||
$token->accessToken,
|
||||
[
|
||||
'fields' => ['name', 'abilities', 'expires_at'],
|
||||
'abilities' => $abilities,
|
||||
],
|
||||
actor: $user,
|
||||
source: static::class
|
||||
);
|
||||
}
|
||||
|
||||
private function ownsToken(PersonalAccessToken $token): bool
|
||||
{
|
||||
$user = $this->getUser();
|
||||
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $token->tokenable_id === (int) $user->getKey()
|
||||
&& $token->tokenable_type === $user->getMorphClass();
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,88 @@ class DokployPlatformHealth extends Widget
|
||||
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$projects = $this->loadProjects();
|
||||
|
||||
return [
|
||||
'composes' => $this->loadComposes(),
|
||||
'projects' => $projects,
|
||||
'composes' => empty($projects) ? $this->loadComposes() : [],
|
||||
];
|
||||
}
|
||||
|
||||
protected function loadProjects(): array
|
||||
{
|
||||
$client = app(DokployClient::class);
|
||||
$projectMap = config('dokploy.projects', []);
|
||||
$results = [];
|
||||
|
||||
if (empty($projectMap)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($projectMap as $label => $projectId) {
|
||||
$project = [];
|
||||
$projectIdString = (string) $projectId;
|
||||
|
||||
try {
|
||||
$project = $client->project($projectIdString);
|
||||
} catch (\Throwable $exception) {
|
||||
$project = [];
|
||||
}
|
||||
|
||||
if (empty($project)) {
|
||||
$project = $client->findProject($projectIdString) ?? [];
|
||||
|
||||
$resolvedProjectId = Arr::get($project, 'projectId');
|
||||
|
||||
if ($resolvedProjectId) {
|
||||
try {
|
||||
$project = $client->project((string) $resolvedProjectId);
|
||||
} catch (\Throwable $exception) {
|
||||
$project = $project;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $project) {
|
||||
$results[] = [
|
||||
'label' => ucfirst((string) $label),
|
||||
'project_id' => $projectIdString,
|
||||
'name' => $projectIdString,
|
||||
'status' => 'unreachable',
|
||||
'error' => "Project {$projectIdString} not found.",
|
||||
'applications' => [],
|
||||
'services' => [],
|
||||
'composes' => [],
|
||||
'updated_at' => null,
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$environments = $this->extractEnvironments($project);
|
||||
$applications = $this->formatEnvironmentApplications($environments, $client);
|
||||
$composes = $this->formatEnvironmentComposes($environments, $client);
|
||||
$services = $this->formatEnvironmentServices($environments);
|
||||
|
||||
$results[] = [
|
||||
'label' => ucfirst((string) $label),
|
||||
'project_id' => Arr::get($project, 'projectId', $projectIdString),
|
||||
'name' => Arr::get($project, 'name') ?? Arr::get($project, 'projectName') ?? $projectIdString,
|
||||
'description' => Arr::get($project, 'description'),
|
||||
'status' => $this->deriveProjectStatus($applications, $services, $composes),
|
||||
'applications' => $applications,
|
||||
'composes' => $composes,
|
||||
'services' => $services,
|
||||
'updated_at' => Arr::get($project, 'updatedAt') ?? Arr::get($project, 'createdAt'),
|
||||
'applications_count' => count($applications),
|
||||
'composes_count' => count($composes),
|
||||
'services_count' => count($services),
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
protected function loadComposes(): array
|
||||
{
|
||||
$client = app(DokployClient::class);
|
||||
@@ -62,7 +139,7 @@ class DokployPlatformHealth extends Widget
|
||||
'label' => 'Dokploy',
|
||||
'compose_id' => '-',
|
||||
'status' => 'unconfigured',
|
||||
'error' => 'Set DOKPLOY_COMPOSE_IDS in .env to enable monitoring.',
|
||||
'error' => 'Set DOKPLOY_PROJECT_IDS or DOKPLOY_COMPOSE_IDS in .env to enable monitoring.',
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -70,6 +147,252 @@ class DokployPlatformHealth extends Widget
|
||||
return $results;
|
||||
}
|
||||
|
||||
protected function extractEnvironments(array $project): array
|
||||
{
|
||||
$environments = Arr::get($project, 'environments', []);
|
||||
|
||||
if (is_array($environments) && ! empty($environments)) {
|
||||
return $environments;
|
||||
}
|
||||
|
||||
return [[
|
||||
'name' => Arr::get($project, 'name'),
|
||||
'applications' => Arr::get($project, 'applications', []),
|
||||
'compose' => Arr::get($project, 'compose', []),
|
||||
'mysql' => Arr::get($project, 'mysql', []),
|
||||
'postgres' => Arr::get($project, 'postgres', []),
|
||||
'mariadb' => Arr::get($project, 'mariadb', []),
|
||||
'mongo' => Arr::get($project, 'mongo', []),
|
||||
'redis' => Arr::get($project, 'redis', []),
|
||||
]];
|
||||
}
|
||||
|
||||
protected function formatEnvironmentApplications(array $environments, DokployClient $client): array
|
||||
{
|
||||
return collect($environments)
|
||||
->flatMap(function (array $environment) use ($client) {
|
||||
$applications = Arr::get($environment, 'applications', []);
|
||||
$environmentName = Arr::get($environment, 'name');
|
||||
|
||||
return $this->formatApplications(is_array($applications) ? $applications : [], $client, $environmentName);
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
protected function formatEnvironmentComposes(array $environments, DokployClient $client): array
|
||||
{
|
||||
return collect($environments)
|
||||
->flatMap(function (array $environment) use ($client) {
|
||||
$composes = Arr::get($environment, 'compose', []);
|
||||
$environmentName = Arr::get($environment, 'name');
|
||||
|
||||
return collect(is_array($composes) ? $composes : [])
|
||||
->map(function (array $compose) use ($client, $environmentName) {
|
||||
$composeId = Arr::get($compose, 'composeId') ?? Arr::get($compose, 'id');
|
||||
$statusPayload = [];
|
||||
$deployments = [];
|
||||
|
||||
if ($composeId) {
|
||||
try {
|
||||
$statusPayload = $client->composeStatus($composeId);
|
||||
$deployments = $client->composeDeployments($composeId, 1);
|
||||
} catch (\Throwable $exception) {
|
||||
$statusPayload = [];
|
||||
$deployments = [];
|
||||
}
|
||||
}
|
||||
|
||||
$composeDetails = Arr::get($statusPayload, 'compose', []);
|
||||
|
||||
return [
|
||||
'id' => $composeId,
|
||||
'name' => Arr::get($compose, 'name')
|
||||
?? Arr::get($compose, 'appName')
|
||||
?? Arr::get($composeDetails, 'name')
|
||||
?? Arr::get($composeDetails, 'appName')
|
||||
?? $composeId,
|
||||
'status' => Arr::get($compose, 'composeStatus')
|
||||
?? Arr::get($compose, 'status')
|
||||
?? Arr::get($composeDetails, 'composeStatus')
|
||||
?? Arr::get($composeDetails, 'status')
|
||||
?? 'unknown',
|
||||
'environment' => $environmentName,
|
||||
'last_deploy' => Arr::get($deployments, '0.createdAt')
|
||||
?? Arr::get($deployments, '0.created_at')
|
||||
?? Arr::get($compose, 'updatedAt')
|
||||
?? Arr::get($composeDetails, 'updatedAt'),
|
||||
'services' => $this->formatServices(Arr::get($statusPayload, 'services', [])),
|
||||
];
|
||||
})
|
||||
->filter(fn (array $compose) => filled($compose['name']))
|
||||
->values()
|
||||
->all();
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
protected function formatEnvironmentServices(array $environments): array
|
||||
{
|
||||
return collect($environments)
|
||||
->flatMap(function (array $environment) {
|
||||
$environmentName = Arr::get($environment, 'name');
|
||||
|
||||
return collect([
|
||||
...$this->normalizeServiceList((array) Arr::get($environment, 'compose', []), 'compose', 'composeId', 'composeStatus', $environmentName),
|
||||
...$this->normalizeServiceList((array) Arr::get($environment, 'mysql', []), 'mysql', 'mysqlId', 'applicationStatus', $environmentName),
|
||||
...$this->normalizeServiceList((array) Arr::get($environment, 'postgres', []), 'postgres', 'postgresId', 'applicationStatus', $environmentName),
|
||||
...$this->normalizeServiceList((array) Arr::get($environment, 'mariadb', []), 'mariadb', 'mariadbId', 'applicationStatus', $environmentName),
|
||||
...$this->normalizeServiceList((array) Arr::get($environment, 'mongo', []), 'mongo', 'mongoId', 'applicationStatus', $environmentName),
|
||||
...$this->normalizeServiceList((array) Arr::get($environment, 'redis', []), 'redis', 'redisId', 'applicationStatus', $environmentName),
|
||||
]);
|
||||
})
|
||||
->filter(fn (array $service) => filled($service['name']))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
protected function formatApplications(array $applications, DokployClient $client, ?string $environment = null): array
|
||||
{
|
||||
return collect($applications)
|
||||
->map(function (array $application) use ($client, $environment) {
|
||||
$applicationId = $this->extractApplicationId($application);
|
||||
$statusPayload = [];
|
||||
|
||||
if ($applicationId) {
|
||||
try {
|
||||
$statusPayload = $client->applicationStatus($applicationId);
|
||||
} catch (\Throwable $exception) {
|
||||
$statusPayload = [];
|
||||
}
|
||||
}
|
||||
|
||||
$applicationDetails = Arr::get($statusPayload, 'application', []);
|
||||
$monitoring = Arr::get($statusPayload, 'monitoring', []);
|
||||
|
||||
$status = Arr::get($application, 'applicationStatus')
|
||||
?? Arr::get($application, 'status')
|
||||
?? Arr::get($applicationDetails, 'applicationStatus')
|
||||
?? Arr::get($applicationDetails, 'status')
|
||||
?? 'unknown';
|
||||
|
||||
return [
|
||||
'id' => $applicationId ?? Arr::get($application, 'id'),
|
||||
'name' => Arr::get($application, 'name')
|
||||
?? Arr::get($application, 'appName')
|
||||
?? Arr::get($applicationDetails, 'name')
|
||||
?? Arr::get($applicationDetails, 'appName')
|
||||
?? $applicationId,
|
||||
'status' => $status,
|
||||
'repository' => Arr::get($application, 'repository')
|
||||
?? Arr::get($applicationDetails, 'repository')
|
||||
?? Arr::get($application, 'repo')
|
||||
?? Arr::get($applicationDetails, 'repo'),
|
||||
'branch' => Arr::get($application, 'branch')
|
||||
?? Arr::get($applicationDetails, 'branch')
|
||||
?? Arr::get($application, 'gitBranch')
|
||||
?? Arr::get($applicationDetails, 'gitBranch'),
|
||||
'url' => Arr::get($application, 'url')
|
||||
?? Arr::get($applicationDetails, 'url')
|
||||
?? Arr::get($application, 'domain')
|
||||
?? Arr::get($applicationDetails, 'domain'),
|
||||
'server' => Arr::get($application, 'serverName')
|
||||
?? Arr::get($applicationDetails, 'serverName')
|
||||
?? Arr::get($application, 'server'),
|
||||
'environment' => $environment,
|
||||
'last_deploy' => Arr::get($application, 'lastDeploymentAt')
|
||||
?? Arr::get($applicationDetails, 'lastDeploymentAt')
|
||||
?? Arr::get($application, 'updatedAt')
|
||||
?? Arr::get($applicationDetails, 'updatedAt')
|
||||
?? Arr::get($application, 'createdAt'),
|
||||
'monitoring' => $this->formatMonitoring($monitoring),
|
||||
];
|
||||
})
|
||||
->filter(fn (array $application) => filled($application['name']))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
protected function extractApplicationId(array $application): ?string
|
||||
{
|
||||
return Arr::get($application, 'applicationId')
|
||||
?? Arr::get($application, 'appId')
|
||||
?? Arr::get($application, 'id');
|
||||
}
|
||||
|
||||
protected function normalizeServiceList(array $services, string $type, string $idKey, string $statusKey, ?string $environment = null): array
|
||||
{
|
||||
return collect($services)
|
||||
->map(function (array $service) use ($type, $idKey, $statusKey, $environment) {
|
||||
return [
|
||||
'type' => $type,
|
||||
'id' => Arr::get($service, $idKey) ?? Arr::get($service, 'id'),
|
||||
'name' => Arr::get($service, 'name') ?? Arr::get($service, 'appName') ?? Arr::get($service, 'serviceName'),
|
||||
'status' => Arr::get($service, $statusKey) ?? Arr::get($service, 'status') ?? Arr::get($service, 'composeStatus', 'unknown'),
|
||||
'version' => Arr::get($service, 'dockerImage') ?? Arr::get($service, 'image'),
|
||||
'external_port' => Arr::get($service, 'externalPort'),
|
||||
'environment' => $environment,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
protected function formatMonitoring(array $monitoring): array
|
||||
{
|
||||
$metrics = [];
|
||||
$allowed = [
|
||||
'cpuPercent' => 'CPU',
|
||||
'cpu' => 'CPU',
|
||||
'memoryPercent' => 'Memory',
|
||||
'memory' => 'Memory',
|
||||
'uptime' => 'Uptime',
|
||||
];
|
||||
|
||||
foreach ($allowed as $key => $label) {
|
||||
$value = Arr::get($monitoring, $key);
|
||||
|
||||
if (filled($value) && ! is_array($value)) {
|
||||
$metrics[] = [
|
||||
'label' => $label,
|
||||
'value' => $value,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $metrics;
|
||||
}
|
||||
|
||||
protected function deriveProjectStatus(array $applications, array $services, array $composes): string
|
||||
{
|
||||
$statuses = collect($applications)
|
||||
->pluck('status')
|
||||
->merge(collect($services)->pluck('status'))
|
||||
->merge(collect($composes)->pluck('status'))
|
||||
->filter()
|
||||
->map(fn ($status) => strtolower((string) $status))
|
||||
->values();
|
||||
|
||||
if ($statuses->contains(fn ($status) => in_array($status, ['error', 'failed', 'unreachable', 'unhealthy'], true))) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
if ($statuses->contains(fn ($status) => in_array($status, ['deploying', 'pending', 'starting'], true))) {
|
||||
return 'deploying';
|
||||
}
|
||||
|
||||
if ($statuses->contains(fn ($status) => in_array($status, ['stopped', 'inactive', 'paused'], true))) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
if ($statuses->contains(fn ($status) => in_array($status, ['done', 'running', 'healthy'], true))) {
|
||||
return 'done';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
protected function formatServices(array $services): array
|
||||
{
|
||||
return collect($services)
|
||||
|
||||
@@ -4,8 +4,8 @@ namespace App\Filament\Widgets;
|
||||
|
||||
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PlatformStatsWidget extends BaseWidget
|
||||
{
|
||||
|
||||
@@ -12,6 +12,8 @@ class QueueHealthWidget extends Widget
|
||||
|
||||
protected ?string $pollingInterval = '60s';
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$snapshot = Cache::get('storage:queue-health:last');
|
||||
|
||||
@@ -7,7 +7,6 @@ use Filament\Widgets\LineChartWidget;
|
||||
|
||||
class RevenueTrendWidget extends LineChartWidget
|
||||
{
|
||||
|
||||
protected static ?int $sort = 1;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use Filament\Tables;
|
||||
use Filament\Widgets\TableWidget as BaseWidget;
|
||||
use App\Models\Tenant;
|
||||
|
||||
class TopTenantsByUploads extends BaseWidget
|
||||
{
|
||||
@@ -14,6 +14,7 @@ class TopTenantsByUploads extends BaseWidget
|
||||
{
|
||||
return __('admin.widgets.top_tenants_by_uploads.heading');
|
||||
}
|
||||
|
||||
protected ?string $pollingInterval = '60s';
|
||||
|
||||
public function table(Tables\Table $table): Tables\Table
|
||||
@@ -33,4 +34,3 @@ class TopTenantsByUploads extends BaseWidget
|
||||
->paginated(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||
|
||||
class QrController extends BaseController
|
||||
@@ -15,7 +15,7 @@ class QrController extends BaseController
|
||||
return response('missing data', 400);
|
||||
}
|
||||
$png = QrCode::format('png')->size(300)->generate($data);
|
||||
|
||||
return response($png, 200, ['Content-Type' => 'image/png']);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -185,6 +185,57 @@ class EventPublicController extends BaseController
|
||||
);
|
||||
}
|
||||
|
||||
$deviceId = (string) $request->header('X-Device-Id', $request->input('device_id', ''));
|
||||
$deviceId = $deviceId !== '' ? $deviceId : null;
|
||||
|
||||
if ($event->id ?? null) {
|
||||
$eventModel = Event::with(['tenant', 'eventPackage.package', 'eventPackages.package'])->find($event->id);
|
||||
if ($eventModel && $eventModel->tenant) {
|
||||
$eventPackage = $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload(
|
||||
$eventModel->tenant,
|
||||
$eventModel->id,
|
||||
$eventModel
|
||||
);
|
||||
$maxGuests = $eventPackage?->effectiveGuestLimit();
|
||||
|
||||
if ($eventPackage && $maxGuests !== null) {
|
||||
$grace = (int) config('package-limits.guest_grace', 10);
|
||||
$hardLimit = $maxGuests + max(0, $grace);
|
||||
$usedGuests = (int) $eventPackage->used_guests;
|
||||
$isReturningGuest = $this->joinTokenService->hasSeenGuest($eventModel->id, $deviceId, $request->ip());
|
||||
|
||||
if ($usedGuests >= $hardLimit && ! $isReturningGuest) {
|
||||
$this->recordTokenEvent(
|
||||
$joinToken,
|
||||
$request,
|
||||
'guest_limit_exceeded',
|
||||
[
|
||||
'event_id' => $eventModel->id,
|
||||
'used' => $usedGuests,
|
||||
'limit' => $maxGuests,
|
||||
'hard_limit' => $hardLimit,
|
||||
],
|
||||
$token,
|
||||
Response::HTTP_PAYMENT_REQUIRED
|
||||
);
|
||||
|
||||
return ApiError::response(
|
||||
'guest_limit_exceeded',
|
||||
__('api.packages.guest_limit_exceeded.title'),
|
||||
__('api.packages.guest_limit_exceeded.message'),
|
||||
Response::HTTP_PAYMENT_REQUIRED,
|
||||
[
|
||||
'event_id' => $eventModel->id,
|
||||
'used' => $usedGuests,
|
||||
'limit' => $maxGuests,
|
||||
'hard_limit' => $hardLimit,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RateLimiter::clear($rateLimiterKey);
|
||||
|
||||
if (isset($event->status)) {
|
||||
@@ -1042,12 +1093,8 @@ class EventPublicController extends BaseController
|
||||
$brandingAllowed = $this->determineBrandingAllowed($event);
|
||||
|
||||
$eventBranding = $brandingAllowed ? Arr::get($event->settings, 'branding', []) : [];
|
||||
$tenantBranding = $brandingAllowed ? Arr::get($event->tenant?->settings, 'branding', []) : [];
|
||||
|
||||
$useDefault = (bool) Arr::get($eventBranding, 'use_default_branding', Arr::get($eventBranding, 'use_default', false));
|
||||
$sources = $brandingAllowed
|
||||
? ($useDefault ? [$tenantBranding] : [$eventBranding, $tenantBranding])
|
||||
: [[]];
|
||||
$sources = $brandingAllowed ? [$eventBranding] : [[]];
|
||||
|
||||
$primary = $this->normalizeHexColor(
|
||||
$this->firstStringFromSources($sources, ['palette.primary', 'primary_color']),
|
||||
@@ -1906,7 +1953,9 @@ class EventPublicController extends BaseController
|
||||
$policy = $this->guestPolicy();
|
||||
|
||||
if ($joinToken) {
|
||||
$this->joinTokenService->incrementUsage($joinToken);
|
||||
$deviceId = (string) $request->header('X-Device-Id', $request->input('device_id', ''));
|
||||
$deviceId = $deviceId !== '' ? $deviceId : null;
|
||||
$this->joinTokenService->incrementUsage($joinToken, $deviceId, $request->ip());
|
||||
}
|
||||
|
||||
$demoReadOnly = (bool) Arr::get($joinToken?->metadata ?? [], 'demo_read_only', false);
|
||||
|
||||
@@ -26,11 +26,11 @@ class LegalController extends BaseController
|
||||
'allow_unsafe_links' => false,
|
||||
]);
|
||||
|
||||
$environment->addExtension(new CommonMarkCoreExtension());
|
||||
$environment->addExtension(new TableExtension());
|
||||
$environment->addExtension(new AutolinkExtension());
|
||||
$environment->addExtension(new StrikethroughExtension());
|
||||
$environment->addExtension(new TaskListExtension());
|
||||
$environment->addExtension(new CommonMarkCoreExtension);
|
||||
$environment->addExtension(new TableExtension);
|
||||
$environment->addExtension(new AutolinkExtension);
|
||||
$environment->addExtension(new StrikethroughExtension);
|
||||
$environment->addExtension(new TaskListExtension);
|
||||
|
||||
$this->markdown = new MarkdownConverter($environment);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Support;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Support\SupportGuestPolicyRequest;
|
||||
use App\Models\GuestPolicySetting;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use App\Support\SupportApiAuthorizer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class SupportGuestPolicyController extends Controller
|
||||
{
|
||||
public function show(): JsonResponse
|
||||
{
|
||||
if ($response = SupportApiAuthorizer::authorizeAbilities(request(), ['support:settings'], 'settings')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$settings = GuestPolicySetting::current();
|
||||
|
||||
return response()->json([
|
||||
'data' => $settings,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(SupportGuestPolicyRequest $request): JsonResponse
|
||||
{
|
||||
if ($response = SupportApiAuthorizer::authorizeAbilities($request, ['support:settings'], 'settings')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$settings = GuestPolicySetting::query()->firstOrNew(['id' => 1]);
|
||||
|
||||
$settings->fill($request->validated());
|
||||
$settings->save();
|
||||
|
||||
$changed = $settings->getChanges();
|
||||
|
||||
if ($changed !== []) {
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'guest_policy.updated',
|
||||
$settings,
|
||||
SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)),
|
||||
source: static::class
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $settings->refresh(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
401
app/Http/Controllers/Api/Support/SupportResourceController.php
Normal file
401
app/Http/Controllers/Api/Support/SupportResourceController.php
Normal file
@@ -0,0 +1,401 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Support;
|
||||
|
||||
use App\Enums\DataExportScope;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Support\Resources\SupportResourceFormRequest;
|
||||
use App\Http\Requests\Support\SupportResourceRequest;
|
||||
use App\Jobs\GenerateDataExport;
|
||||
use App\Models\DataExport;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use App\Support\ApiError;
|
||||
use App\Support\SupportApiAuthorizer;
|
||||
use App\Support\SupportApiRegistry;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class SupportResourceController extends Controller
|
||||
{
|
||||
public function index(Request $request, string $resource): JsonResponse
|
||||
{
|
||||
if ($response = SupportApiAuthorizer::authorizeResource($request, $resource, 'read')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$config = SupportApiRegistry::get($resource);
|
||||
if (! $config) {
|
||||
return $this->resourceNotFoundResponse($resource);
|
||||
}
|
||||
|
||||
$modelClass = $config['model'];
|
||||
/** @var Builder $query */
|
||||
$query = $modelClass::query();
|
||||
|
||||
$relations = SupportApiRegistry::withRelations($resource);
|
||||
if ($relations !== []) {
|
||||
$query->with($relations);
|
||||
}
|
||||
|
||||
$this->applySearch($request, $query, $resource);
|
||||
$this->applySorting($request, $query, $resource);
|
||||
|
||||
$perPage = $this->resolvePerPage($request);
|
||||
$paginator = $query->paginate($perPage);
|
||||
|
||||
return response()->json([
|
||||
'data' => $paginator->items(),
|
||||
'meta' => [
|
||||
'page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $resource, string $record): JsonResponse
|
||||
{
|
||||
if ($response = SupportApiAuthorizer::authorizeResource($request, $resource, 'read')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$model = $this->resolveRecord($resource, $record);
|
||||
|
||||
if (! $model) {
|
||||
return $this->resourceNotFoundResponse($resource, $record);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $model,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(SupportResourceRequest $request, string $resource): JsonResponse
|
||||
{
|
||||
if ($response = SupportApiAuthorizer::authorizeAnyAbility($request, SupportApiRegistry::abilitiesFor($resource, 'write'), 'write')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if (! SupportApiRegistry::allowsMutation($resource, 'create')) {
|
||||
return $this->mutationNotAllowedResponse($resource, 'create');
|
||||
}
|
||||
|
||||
$config = SupportApiRegistry::get($resource);
|
||||
if (! $config) {
|
||||
return $this->resourceNotFoundResponse($resource);
|
||||
}
|
||||
|
||||
$modelClass = $config['model'];
|
||||
/** @var Model $model */
|
||||
$model = new $modelClass;
|
||||
|
||||
$payload = $this->validatedPayload($request, $resource, 'create', $model);
|
||||
|
||||
if ($payload instanceof JsonResponse) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
if ($payload === []) {
|
||||
return $this->emptyPayloadResponse($resource);
|
||||
}
|
||||
|
||||
if ($resource === 'data-exports') {
|
||||
$payload = $this->normalizeDataExportPayload($request, $payload);
|
||||
}
|
||||
|
||||
$record = $modelClass::query()->create($payload);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
SupportApiRegistry::auditAction($resource, 'created'),
|
||||
$record,
|
||||
SuperAdminAuditLogger::fieldsMetadata($payload),
|
||||
actor: $request->user(),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
if ($resource === 'data-exports') {
|
||||
GenerateDataExport::dispatch($record->id);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $record,
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function update(SupportResourceRequest $request, string $resource, string $record): JsonResponse
|
||||
{
|
||||
if ($response = SupportApiAuthorizer::authorizeAnyAbility($request, SupportApiRegistry::abilitiesFor($resource, 'write'), 'write')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if (! SupportApiRegistry::allowsMutation($resource, 'update')) {
|
||||
return $this->mutationNotAllowedResponse($resource, 'update');
|
||||
}
|
||||
|
||||
$model = $this->resolveRecord($resource, $record);
|
||||
|
||||
if (! $model) {
|
||||
return $this->resourceNotFoundResponse($resource, $record);
|
||||
}
|
||||
|
||||
$payload = $this->validatedPayload($request, $resource, 'update', $model);
|
||||
|
||||
if ($payload instanceof JsonResponse) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
if ($payload === []) {
|
||||
return $this->emptyPayloadResponse($resource);
|
||||
}
|
||||
|
||||
$model->fill($payload);
|
||||
$model->save();
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
SupportApiRegistry::auditAction($resource, 'updated'),
|
||||
$model,
|
||||
SuperAdminAuditLogger::fieldsMetadata($payload),
|
||||
actor: $request->user(),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $model->refresh(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, string $resource, string $record): JsonResponse
|
||||
{
|
||||
if ($response = SupportApiAuthorizer::authorizeAnyAbility($request, SupportApiRegistry::abilitiesFor($resource, 'write'), 'write')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if (! SupportApiRegistry::allowsMutation($resource, 'delete')) {
|
||||
return $this->mutationNotAllowedResponse($resource, 'delete');
|
||||
}
|
||||
|
||||
$model = $this->resolveRecord($resource, $record);
|
||||
|
||||
if (! $model) {
|
||||
return $this->resourceNotFoundResponse($resource, $record);
|
||||
}
|
||||
|
||||
$model->delete();
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
SupportApiRegistry::auditAction($resource, 'deleted'),
|
||||
$model,
|
||||
SuperAdminAuditLogger::fieldsMetadata([]),
|
||||
actor: $request->user(),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
private function resolveRecord(string $resource, string $record): ?Model
|
||||
{
|
||||
$config = SupportApiRegistry::get($resource);
|
||||
|
||||
if (! $config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$modelClass = $config['model'];
|
||||
|
||||
$query = $modelClass::query();
|
||||
|
||||
if (is_numeric($record)) {
|
||||
return $query->find($record);
|
||||
}
|
||||
|
||||
$keyName = (new $modelClass)->getKeyName();
|
||||
|
||||
return $query->where($keyName, $record)->first();
|
||||
}
|
||||
|
||||
private function validatedPayload(SupportResourceRequest $request, string $resource, string $action, Model $model): array|JsonResponse
|
||||
{
|
||||
$payload = $request->validated('data');
|
||||
|
||||
if (! is_array($payload)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$validationClass = SupportApiRegistry::validationClass($resource, $action);
|
||||
|
||||
if ($validationClass && is_subclass_of($validationClass, SupportResourceFormRequest::class)) {
|
||||
$allowedFields = $validationClass::allowedFields($action);
|
||||
|
||||
if ($allowedFields !== []) {
|
||||
$unexpected = array_diff(array_keys($payload), $allowedFields);
|
||||
if ($unexpected !== []) {
|
||||
return $this->invalidFieldResponse($resource, $unexpected);
|
||||
}
|
||||
}
|
||||
|
||||
$rules = $validationClass::rulesFor($action, $model);
|
||||
if ($rules !== []) {
|
||||
$payload = Validator::make($payload, $rules)->validate();
|
||||
}
|
||||
|
||||
if ($allowedFields !== []) {
|
||||
$payload = Arr::only($payload, $allowedFields);
|
||||
}
|
||||
}
|
||||
|
||||
$fillable = $model->getFillable();
|
||||
|
||||
if ($fillable === [] && method_exists($model, 'getGuarded') && $model->getGuarded() !== ['*']) {
|
||||
$columns = Schema::getColumnListing($model->getTable());
|
||||
|
||||
return Arr::only($payload, $columns);
|
||||
}
|
||||
|
||||
if ($fillable === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Arr::only($payload, $fillable);
|
||||
}
|
||||
|
||||
private function applySearch(Request $request, Builder $query, string $resource): void
|
||||
{
|
||||
$term = $request->string('search')->trim()->value();
|
||||
|
||||
if ($term === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$fields = SupportApiRegistry::searchFields($resource);
|
||||
|
||||
if ($fields === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$columns = Schema::getColumnListing($query->getModel()->getTable());
|
||||
$fields = array_values(array_intersect($fields, $columns));
|
||||
|
||||
if ($fields === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$query->where(function (Builder $builder) use ($fields, $term): void {
|
||||
foreach ($fields as $field) {
|
||||
if ($field === 'id' && is_numeric($term)) {
|
||||
$builder->orWhere($field, (int) $term);
|
||||
} else {
|
||||
$builder->orWhere($field, 'like', "%{$term}%");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function applySorting(Request $request, Builder $query, string $resource): void
|
||||
{
|
||||
$sort = $request->string('sort')->trim()->value();
|
||||
|
||||
if ($sort === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$direction = 'asc';
|
||||
$field = $sort;
|
||||
|
||||
if (str_starts_with($sort, '-')) {
|
||||
$direction = 'desc';
|
||||
$field = ltrim($sort, '-');
|
||||
}
|
||||
|
||||
$allowed = SupportApiRegistry::searchFields($resource);
|
||||
$allowed[] = 'id';
|
||||
|
||||
$columns = Schema::getColumnListing($query->getModel()->getTable());
|
||||
$allowed = array_values(array_intersect($allowed, $columns));
|
||||
|
||||
if (! in_array($field, $allowed, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$query->orderBy($field, $direction);
|
||||
}
|
||||
|
||||
private function resolvePerPage(Request $request): int
|
||||
{
|
||||
$default = (int) config('support-api.pagination.default_per_page', 50);
|
||||
$max = (int) config('support-api.pagination.max_per_page', 200);
|
||||
|
||||
$perPage = (int) $request->input('per_page', $default);
|
||||
|
||||
if ($perPage < 1) {
|
||||
$perPage = $default;
|
||||
}
|
||||
|
||||
return min($perPage, $max);
|
||||
}
|
||||
|
||||
private function mutationNotAllowedResponse(string $resource, string $action): JsonResponse
|
||||
{
|
||||
return ApiError::response(
|
||||
'support_mutation_not_allowed',
|
||||
'Mutation Not Allowed',
|
||||
"{$resource} does not allow {$action} operations in support API.",
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
private function emptyPayloadResponse(string $resource): JsonResponse
|
||||
{
|
||||
return ApiError::response(
|
||||
'support_invalid_payload',
|
||||
'Invalid Payload',
|
||||
"No mutable fields provided for {$resource}.",
|
||||
422
|
||||
);
|
||||
}
|
||||
|
||||
private function invalidFieldResponse(string $resource, array $fields): JsonResponse
|
||||
{
|
||||
return ApiError::response(
|
||||
'support_invalid_fields',
|
||||
'Invalid Fields',
|
||||
"Unsupported fields provided for {$resource}.",
|
||||
422,
|
||||
[
|
||||
'fields' => array_values($fields),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function resourceNotFoundResponse(string $resource, ?string $record = null): JsonResponse
|
||||
{
|
||||
$message = $record
|
||||
? "{$resource} record not found."
|
||||
: "Support resource {$resource} is not registered.";
|
||||
|
||||
return ApiError::response(
|
||||
'support_resource_not_found',
|
||||
'Not Found',
|
||||
$message,
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
private function normalizeDataExportPayload(Request $request, array $payload): array
|
||||
{
|
||||
$payload['user_id'] = $request->user()?->id;
|
||||
$payload['status'] = DataExport::STATUS_PENDING;
|
||||
|
||||
if (($payload['scope'] ?? null) !== DataExportScope::EVENT->value) {
|
||||
$payload['event_id'] = null;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Support;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Support\Tenant\SupportTenantAddPackageRequest;
|
||||
use App\Http\Requests\Support\Tenant\SupportTenantScheduleDeletionRequest;
|
||||
use App\Http\Requests\Support\Tenant\SupportTenantSetGracePeriodRequest;
|
||||
use App\Http\Requests\Support\Tenant\SupportTenantUpdateLimitsRequest;
|
||||
use App\Http\Requests\Support\Tenant\SupportTenantUpdateSubscriptionRequest;
|
||||
use App\Jobs\AnonymizeAccount;
|
||||
use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Notifications\InactiveTenantDeletionWarning;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use App\Services\Tenant\TenantLifecycleLogger;
|
||||
use App\Support\SupportApiAuthorizer;
|
||||
use Carbon\Carbon;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Notification as NotificationFacade;
|
||||
|
||||
class SupportTenantActionsController extends Controller
|
||||
{
|
||||
public function activate(Tenant $tenant): JsonResponse
|
||||
{
|
||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$updated = $tenant->update(['is_active' => true]);
|
||||
|
||||
app(TenantLifecycleLogger::class)->record(
|
||||
$tenant,
|
||||
'activated',
|
||||
actor: auth()->user()
|
||||
);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'tenant.activated',
|
||||
$tenant,
|
||||
SuperAdminAuditLogger::fieldsMetadata(['is_active']),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json(['ok' => $updated]);
|
||||
}
|
||||
|
||||
public function deactivate(Tenant $tenant): JsonResponse
|
||||
{
|
||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$updated = $tenant->update(['is_active' => false]);
|
||||
|
||||
app(TenantLifecycleLogger::class)->record(
|
||||
$tenant,
|
||||
'deactivated',
|
||||
actor: auth()->user()
|
||||
);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'tenant.deactivated',
|
||||
$tenant,
|
||||
SuperAdminAuditLogger::fieldsMetadata(['is_active']),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json(['ok' => $updated]);
|
||||
}
|
||||
|
||||
public function suspend(Tenant $tenant): JsonResponse
|
||||
{
|
||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$updated = $tenant->update(['is_suspended' => true]);
|
||||
|
||||
app(TenantLifecycleLogger::class)->record(
|
||||
$tenant,
|
||||
'suspended',
|
||||
actor: auth()->user()
|
||||
);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'tenant.suspended',
|
||||
$tenant,
|
||||
SuperAdminAuditLogger::fieldsMetadata(['is_suspended']),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json(['ok' => $updated]);
|
||||
}
|
||||
|
||||
public function unsuspend(Tenant $tenant): JsonResponse
|
||||
{
|
||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$updated = $tenant->update(['is_suspended' => false]);
|
||||
|
||||
app(TenantLifecycleLogger::class)->record(
|
||||
$tenant,
|
||||
'unsuspended',
|
||||
actor: auth()->user()
|
||||
);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'tenant.unsuspended',
|
||||
$tenant,
|
||||
SuperAdminAuditLogger::fieldsMetadata(['is_suspended']),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json(['ok' => $updated]);
|
||||
}
|
||||
|
||||
public function scheduleDeletion(SupportTenantScheduleDeletionRequest $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$plannedDeletion = Carbon::parse($request->string('pending_deletion_at')->value());
|
||||
$update = [
|
||||
'pending_deletion_at' => $plannedDeletion,
|
||||
];
|
||||
|
||||
if ($request->boolean('send_warning', true)) {
|
||||
$email = $tenant->contact_email
|
||||
?? $tenant->email
|
||||
?? $tenant->user?->email;
|
||||
|
||||
if ($email) {
|
||||
NotificationFacade::route('mail', $email)
|
||||
->notify(new InactiveTenantDeletionWarning($tenant, $plannedDeletion));
|
||||
$update['deletion_warning_sent_at'] = now();
|
||||
} else {
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title(__('admin.tenants.actions.send_warning_missing_title'))
|
||||
->body(__('admin.tenants.actions.send_warning_missing_body'))
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
$tenant->forceFill($update)->save();
|
||||
|
||||
app(TenantLifecycleLogger::class)->record(
|
||||
$tenant,
|
||||
'deletion_scheduled',
|
||||
[
|
||||
'pending_deletion_at' => $plannedDeletion->toDateTimeString(),
|
||||
'send_warning' => $request->boolean('send_warning', true),
|
||||
],
|
||||
auth()->user()
|
||||
);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'tenant.deletion_scheduled',
|
||||
$tenant,
|
||||
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function cancelDeletion(Tenant $tenant): JsonResponse
|
||||
{
|
||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$previous = $tenant->pending_deletion_at?->toDateTimeString();
|
||||
|
||||
$tenant->forceFill([
|
||||
'pending_deletion_at' => null,
|
||||
'deletion_warning_sent_at' => null,
|
||||
])->save();
|
||||
|
||||
app(TenantLifecycleLogger::class)->record(
|
||||
$tenant,
|
||||
'deletion_cancelled',
|
||||
['pending_deletion_at' => $previous],
|
||||
auth()->user()
|
||||
);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'tenant.deletion_cancelled',
|
||||
$tenant,
|
||||
SuperAdminAuditLogger::fieldsMetadata(['pending_deletion_at', 'deletion_warning_sent_at']),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function anonymize(Tenant $tenant): JsonResponse
|
||||
{
|
||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
AnonymizeAccount::dispatch(null, $tenant->id);
|
||||
|
||||
app(TenantLifecycleLogger::class)->record(
|
||||
$tenant,
|
||||
'anonymize_requested',
|
||||
actor: auth()->user()
|
||||
);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'tenant.anonymize_requested',
|
||||
$tenant,
|
||||
SuperAdminAuditLogger::fieldsMetadata([]),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function addPackage(SupportTenantAddPackageRequest $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$package = Package::query()->find($request->integer('package_id'));
|
||||
|
||||
TenantPackage::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $request->integer('package_id'),
|
||||
'expires_at' => $request->date('expires_at'),
|
||||
'active' => true,
|
||||
'price' => $package?->price ?? 0,
|
||||
'reason' => $request->string('reason')->value(),
|
||||
]);
|
||||
|
||||
PackagePurchase::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $request->integer('package_id'),
|
||||
'provider' => 'manual',
|
||||
'provider_id' => 'manual',
|
||||
'type' => 'reseller_subscription',
|
||||
'price' => 0,
|
||||
'metadata' => ['reason' => $request->string('reason')->value() ?: 'manual assignment'],
|
||||
]);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'tenant.package_added',
|
||||
$tenant,
|
||||
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function updateLimits(SupportTenantUpdateLimitsRequest $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$before = [
|
||||
'max_photos_per_event' => $tenant->max_photos_per_event,
|
||||
'max_storage_mb' => $tenant->max_storage_mb,
|
||||
];
|
||||
|
||||
$tenant->forceFill([
|
||||
'max_photos_per_event' => $request->integer('max_photos_per_event'),
|
||||
'max_storage_mb' => $request->integer('max_storage_mb'),
|
||||
])->save();
|
||||
|
||||
$after = [
|
||||
'max_photos_per_event' => $tenant->max_photos_per_event,
|
||||
'max_storage_mb' => $tenant->max_storage_mb,
|
||||
];
|
||||
|
||||
app(TenantLifecycleLogger::class)->record(
|
||||
$tenant,
|
||||
'limits_updated',
|
||||
[
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
'note' => $request->string('note')->value(),
|
||||
],
|
||||
auth()->user()
|
||||
);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'tenant.limits_updated',
|
||||
$tenant,
|
||||
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function updateSubscriptionExpiresAt(SupportTenantUpdateSubscriptionRequest $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$before = [
|
||||
'subscription_expires_at' => optional($tenant->subscription_expires_at)->toDateTimeString(),
|
||||
];
|
||||
|
||||
$tenant->forceFill([
|
||||
'subscription_expires_at' => $request->date('subscription_expires_at'),
|
||||
])->save();
|
||||
|
||||
$after = [
|
||||
'subscription_expires_at' => optional($tenant->subscription_expires_at)->toDateTimeString(),
|
||||
];
|
||||
|
||||
app(TenantLifecycleLogger::class)->record(
|
||||
$tenant,
|
||||
'subscription_expires_at_updated',
|
||||
[
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
'note' => $request->string('note')->value(),
|
||||
],
|
||||
auth()->user()
|
||||
);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'tenant.subscription_expires_at_updated',
|
||||
$tenant,
|
||||
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function setGracePeriod(SupportTenantSetGracePeriodRequest $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$tenant->forceFill([
|
||||
'grace_period_ends_at' => $request->date('grace_period_ends_at'),
|
||||
])->save();
|
||||
|
||||
app(TenantLifecycleLogger::class)->record(
|
||||
$tenant,
|
||||
'grace_period_set',
|
||||
[
|
||||
'grace_period_ends_at' => optional($tenant->grace_period_ends_at)->toDateTimeString(),
|
||||
'note' => $request->string('note')->value(),
|
||||
],
|
||||
auth()->user()
|
||||
);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'tenant.grace_period_set',
|
||||
$tenant,
|
||||
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function clearGracePeriod(Tenant $tenant): JsonResponse
|
||||
{
|
||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$previous = $tenant->grace_period_ends_at?->toDateTimeString();
|
||||
|
||||
$tenant->forceFill([
|
||||
'grace_period_ends_at' => null,
|
||||
])->save();
|
||||
|
||||
app(TenantLifecycleLogger::class)->record(
|
||||
$tenant,
|
||||
'grace_period_cleared',
|
||||
[
|
||||
'grace_period_ends_at' => $previous,
|
||||
],
|
||||
auth()->user()
|
||||
);
|
||||
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'tenant.grace_period_cleared',
|
||||
$tenant,
|
||||
SuperAdminAuditLogger::fieldsMetadata(['grace_period_ends_at']),
|
||||
source: static::class
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
private function authorizeAction(string $resource, string $action): ?JsonResponse
|
||||
{
|
||||
return SupportApiAuthorizer::authorizeResource(request(), $resource, $action);
|
||||
}
|
||||
}
|
||||
103
app/Http/Controllers/Api/Support/SupportTokenController.php
Normal file
103
app/Http/Controllers/Api/Support/SupportTokenController.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Support;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Support\SupportTokenRequest;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class SupportTokenController extends Controller
|
||||
{
|
||||
public function store(SupportTokenRequest $request): JsonResponse
|
||||
{
|
||||
$credentials = $request->credentials();
|
||||
|
||||
$query = User::query();
|
||||
|
||||
if (isset($credentials['email'])) {
|
||||
$query->where('email', $credentials['email']);
|
||||
}
|
||||
|
||||
if (isset($credentials['username'])) {
|
||||
$query->where('username', $credentials['username']);
|
||||
}
|
||||
|
||||
/** @var User|null $user */
|
||||
$user = $query->first();
|
||||
|
||||
if (! $user || ! Hash::check($credentials['password'], (string) $user->password)) {
|
||||
throw ValidationException::withMessages([
|
||||
'login' => [trans('auth.failed')],
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $user->isSuperAdmin()) {
|
||||
throw ValidationException::withMessages([
|
||||
'login' => [trans('auth.not_authorized')],
|
||||
]);
|
||||
}
|
||||
|
||||
$tokenConfig = config('support-api.token');
|
||||
$defaultAbilities = $tokenConfig['default_abilities'] ?? [];
|
||||
$abilities = $credentials['abilities'] ?? $defaultAbilities;
|
||||
|
||||
if ($abilities !== $defaultAbilities) {
|
||||
$abilities = array_values(array_intersect($abilities, $defaultAbilities));
|
||||
}
|
||||
|
||||
if (! in_array('support-admin', $abilities, true)) {
|
||||
$abilities[] = 'support-admin';
|
||||
}
|
||||
|
||||
$tokenName = (string) ($tokenConfig['name'] ?? 'support-api');
|
||||
|
||||
$user->tokens()->where('name', $tokenName)->delete();
|
||||
|
||||
$token = $user->createToken($tokenName, $abilities);
|
||||
|
||||
return response()->json([
|
||||
'token' => $token->plainTextToken,
|
||||
'token_type' => 'Bearer',
|
||||
'abilities' => $abilities,
|
||||
'user' => Arr::only($user->toArray(), [
|
||||
'id',
|
||||
'email',
|
||||
'name',
|
||||
'role',
|
||||
'tenant_id',
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request): JsonResponse
|
||||
{
|
||||
$token = $request->user()?->currentAccessToken();
|
||||
|
||||
if ($token) {
|
||||
$token->delete();
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function me(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
return response()->json([
|
||||
'user' => $user ? Arr::only($user->toArray(), [
|
||||
'id',
|
||||
'name',
|
||||
'email',
|
||||
'role',
|
||||
'tenant_id',
|
||||
]) : null,
|
||||
'abilities' => $user?->currentAccessToken()?->abilities ?? [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Support;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Support\SupportWatermarkSettingsRequest;
|
||||
use App\Models\WatermarkSetting;
|
||||
use App\Services\Audit\SuperAdminAuditLogger;
|
||||
use App\Support\SupportApiAuthorizer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class SupportWatermarkSettingsController extends Controller
|
||||
{
|
||||
public function show(): JsonResponse
|
||||
{
|
||||
if ($response = SupportApiAuthorizer::authorizeAbilities(request(), ['support:settings'], 'settings')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$settings = WatermarkSetting::query()->first();
|
||||
|
||||
return response()->json([
|
||||
'data' => $settings,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(SupportWatermarkSettingsRequest $request): JsonResponse
|
||||
{
|
||||
if ($response = SupportApiAuthorizer::authorizeAbilities($request, ['support:settings'], 'settings')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$settings = WatermarkSetting::query()->firstOrNew([]);
|
||||
$settings->fill($request->validated());
|
||||
$settings->save();
|
||||
|
||||
$changed = $settings->getChanges();
|
||||
|
||||
if ($changed !== []) {
|
||||
app(SuperAdminAuditLogger::class)->record(
|
||||
'watermark_settings.updated',
|
||||
$settings,
|
||||
SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)),
|
||||
source: static::class
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $settings->refresh(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ use App\Models\Event;
|
||||
use App\Services\Analytics\EventAnalyticsService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class EventAnalyticsController extends Controller
|
||||
{
|
||||
@@ -23,13 +22,13 @@ class EventAnalyticsController extends Controller
|
||||
if (is_string($packageFeatures)) {
|
||||
$packageFeatures = json_decode($packageFeatures, true) ?? [];
|
||||
}
|
||||
|
||||
|
||||
$hasAccess = in_array('advanced_analytics', $packageFeatures, true);
|
||||
|
||||
if (!$hasAccess) {
|
||||
return response()->json([
|
||||
if (! $hasAccess) {
|
||||
return response()->json([
|
||||
'message' => 'This feature is only available in the Premium package.',
|
||||
'code' => 'feature_locked'
|
||||
'code' => 'feature_locked',
|
||||
], 403);
|
||||
}
|
||||
|
||||
|
||||
@@ -161,11 +161,13 @@ class EventController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
$resolvedName = $this->resolveEventNameString($validated['name']);
|
||||
$eventData = array_merge($validated, [
|
||||
'tenant_id' => $tenantId,
|
||||
'status' => $validated['status'] ?? 'draft',
|
||||
'slug' => $this->generateUniqueSlug($validated['name'], $tenantId),
|
||||
'slug' => $this->generateUniqueSlug($resolvedName, $tenantId),
|
||||
]);
|
||||
$eventData['name'] = $this->normalizeEventName($validated['name']);
|
||||
|
||||
if (isset($eventData['event_date'])) {
|
||||
$eventData['date'] = $eventData['event_date'];
|
||||
@@ -228,7 +230,7 @@ class EventController extends Controller
|
||||
]);
|
||||
|
||||
if ($billingIsReseller && ! $isSuperAdmin) {
|
||||
$note = sprintf('Event #%d created (%s)', $event->id, $event->name);
|
||||
$note = sprintf('Event #%d created (%s)', $event->id, $this->resolveEventNameString($event->name));
|
||||
|
||||
if (! $tenant->consumeEventAllowanceFor($eventServicePackage->slug, 1, 'event.create', $note)) {
|
||||
throw new HttpException(402, 'Insufficient package allowance.');
|
||||
@@ -404,9 +406,13 @@ class EventController extends Controller
|
||||
unset($validated['event_date']);
|
||||
}
|
||||
|
||||
if ($nameProvided && $validated['name'] !== $event->name) {
|
||||
$validated['slug'] = $this->generateUniqueSlug($validated['name'], $tenantId, $event->id);
|
||||
$currentName = $this->resolveEventNameString($event->name);
|
||||
$nextName = $this->resolveEventNameString($validated['name']);
|
||||
|
||||
if ($nameProvided && $nextName !== $currentName) {
|
||||
$validated['slug'] = $this->generateUniqueSlug($nextName, $tenantId, $event->id);
|
||||
}
|
||||
$validated['name'] = $this->normalizeEventName($validated['name']);
|
||||
|
||||
foreach (['password', 'password_confirmation', 'password_protected'] as $unused) {
|
||||
unset($validated[$unused]);
|
||||
@@ -935,6 +941,45 @@ class EventController extends Controller
|
||||
return $slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|string|null $name
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normalizeEventName(mixed $name): array
|
||||
{
|
||||
if (is_array($name)) {
|
||||
return $name;
|
||||
}
|
||||
|
||||
$value = is_string($name) ? trim($name) : '';
|
||||
|
||||
return ['de' => $value];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|string|null $name
|
||||
*/
|
||||
private function resolveEventNameString(mixed $name): string
|
||||
{
|
||||
if (is_array($name)) {
|
||||
$candidates = [
|
||||
$name['de'] ?? null,
|
||||
$name['en'] ?? null,
|
||||
reset($name) ?: null,
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (is_string($candidate) && $candidate !== '') {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
return is_string($name) ? $name : '';
|
||||
}
|
||||
|
||||
public function search(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
@@ -22,7 +22,14 @@ class EventJoinTokenLayoutController extends Controller
|
||||
*/
|
||||
private const BACKGROUND_PRESETS = [
|
||||
'bg-blue-floral' => 'storage/layouts/backgrounds-portrait/bg-blue-floral.png',
|
||||
'bg-artdeco' => 'storage/layouts/backgrounds-portrait/bg-artdeco.png',
|
||||
'bg-eukalyptus-floral' => 'storage/layouts/backgrounds-portrait/bg-eukalyptus-floral.png',
|
||||
'bg-eukalyptus-rahmen' => 'storage/layouts/backgrounds-portrait/bg-eukalyptus-rahmen.png',
|
||||
'bg-eukalyptus' => 'storage/layouts/backgrounds-portrait/bg-eukalyptus.png',
|
||||
'bg-goldframe' => 'storage/layouts/backgrounds-portrait/bg-goldframe.png',
|
||||
'bg-jugendstil' => 'storage/layouts/backgrounds-portrait/bg-jugendstil.png',
|
||||
'bg-kornblumen' => 'storage/layouts/backgrounds-portrait/bg-kornblumen.png',
|
||||
'bg-kornblumen2' => 'storage/layouts/backgrounds-portrait/bg-kornblumen2.png',
|
||||
'gr-green-floral' => 'storage/layouts/backgrounds-portrait/gr-green-floral.png',
|
||||
];
|
||||
|
||||
|
||||
@@ -112,4 +112,3 @@ class FontController extends Controller
|
||||
return $fonts;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ use App\Support\WatermarkConfigResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -115,6 +116,7 @@ class PhotoController extends Controller
|
||||
$event = Event::where('slug', $eventSlug)
|
||||
->where('tenant_id', $tenantId)
|
||||
->firstOrFail();
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||
|
||||
if ($photo->event_id !== $event->id) {
|
||||
return ApiError::response(
|
||||
@@ -321,7 +323,7 @@ class PhotoController extends Controller
|
||||
$disk = $this->eventStorageManager->getHotDiskForEvent($event);
|
||||
|
||||
// Generate unique filename
|
||||
$extension = $file->getClientOriginalExtension();
|
||||
$extension = $this->resolvePhotoExtension($file);
|
||||
$filename = Str::uuid().'.'.$extension;
|
||||
$path = "events/{$eventSlug}/photos/{$filename}";
|
||||
|
||||
@@ -563,6 +565,7 @@ class PhotoController extends Controller
|
||||
$event = Event::where('slug', $eventSlug)
|
||||
->where('tenant_id', $tenantId)
|
||||
->firstOrFail();
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||
|
||||
if ($photo->event_id !== $event->id) {
|
||||
return ApiError::response(
|
||||
@@ -779,6 +782,7 @@ class PhotoController extends Controller
|
||||
$event = Event::where('slug', $eventSlug)
|
||||
->where('tenant_id', $tenantId)
|
||||
->firstOrFail();
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||
|
||||
$photos = Photo::where('event_id', $event->id)
|
||||
->where('status', 'pending')
|
||||
@@ -1043,4 +1047,23 @@ class PhotoController extends Controller
|
||||
|
||||
return array_values(array_unique(array_filter($candidates)));
|
||||
}
|
||||
|
||||
private function resolvePhotoExtension(UploadedFile $file): string
|
||||
{
|
||||
$extension = strtolower((string) $file->extension());
|
||||
|
||||
if (! in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) {
|
||||
$extension = strtolower((string) $file->getClientOriginalExtension());
|
||||
}
|
||||
|
||||
if (! in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) {
|
||||
$extension = match ($file->getMimeType()) {
|
||||
'image/png' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
default => 'jpg',
|
||||
};
|
||||
}
|
||||
|
||||
return $extension === 'jpeg' ? 'jpg' : $extension;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,14 +91,14 @@ class PhotoboothController extends Controller
|
||||
$recipientName = $user->fullName ?? $user->name ?? $user->email;
|
||||
|
||||
$mail = (new PhotoboothUploaderDownload(
|
||||
recipientName: $recipientName,
|
||||
eventName: $eventName,
|
||||
links: [
|
||||
'windows' => url('/downloads/PhotoboothUploader-win-x64.exe'),
|
||||
'macos' => url('/downloads/PhotoboothUploader-macos-x64'),
|
||||
'linux' => url('/downloads/PhotoboothUploader-linux-x64'),
|
||||
],
|
||||
))->locale($locale);
|
||||
recipientName: $recipientName,
|
||||
eventName: $eventName,
|
||||
links: [
|
||||
'windows' => url('/downloads/PhotoboothUploader-win-x64.exe'),
|
||||
'macos' => url('/downloads/PhotoboothUploader-macos-x64'),
|
||||
'linux' => url('/downloads/PhotoboothUploader-linux-x64'),
|
||||
],
|
||||
))->locale($locale);
|
||||
|
||||
Mail::to($user->email)->queue($mail);
|
||||
|
||||
|
||||
@@ -157,6 +157,10 @@ class AuthenticatedSessionController extends Controller
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($candidate, '//')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($candidate, '/')) {
|
||||
return $candidate;
|
||||
}
|
||||
@@ -170,7 +174,7 @@ class AuthenticatedSessionController extends Controller
|
||||
|
||||
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
||||
|
||||
if ($appHost && ! Str::endsWith($targetHost, $appHost)) {
|
||||
if (! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -222,7 +226,7 @@ class AuthenticatedSessionController extends Controller
|
||||
$scheme = $parsed['scheme'] ?? null;
|
||||
$requestHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
||||
|
||||
if ($scheme && $host && $requestHost && ! Str::endsWith($host, $requestHost)) {
|
||||
if ($scheme && $host && $requestHost && ! $this->isAllowedReturnHost($host, $requestHost)) {
|
||||
return '/event-admin/dashboard';
|
||||
}
|
||||
|
||||
@@ -265,6 +269,15 @@ class AuthenticatedSessionController extends Controller
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function isAllowedReturnHost(string $targetHost, string $appHost): bool
|
||||
{
|
||||
if ($targetHost === $appHost) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Str::endsWith($targetHost, '.'.$appHost);
|
||||
}
|
||||
|
||||
private function rememberTenantAdminTarget(Request $request, ?string $target): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
@@ -48,6 +48,9 @@ class CheckoutController extends Controller
|
||||
$googleStatus = session()->pull('checkout_google_status');
|
||||
$googleError = session()->pull('checkout_google_error');
|
||||
$googleProfile = session()->pull('checkout_google_profile');
|
||||
$facebookStatus = session()->pull('checkout_facebook_status');
|
||||
$facebookError = session()->pull('checkout_facebook_error');
|
||||
$facebookProfile = session()->pull('checkout_facebook_profile');
|
||||
|
||||
$packageOptions = Package::orderBy('price')->get()
|
||||
->map(fn (Package $pkg) => $this->presentPackage($pkg))
|
||||
@@ -66,6 +69,11 @@ class CheckoutController extends Controller
|
||||
'error' => $googleError,
|
||||
'profile' => $googleProfile,
|
||||
],
|
||||
'facebookAuth' => [
|
||||
'status' => $facebookStatus,
|
||||
'error' => $facebookError,
|
||||
'profile' => $facebookProfile,
|
||||
],
|
||||
'paddle' => [
|
||||
'environment' => config('paddle.environment'),
|
||||
'client_token' => config('paddle.client_token'),
|
||||
|
||||
217
app/Http/Controllers/CheckoutFacebookController.php
Normal file
217
app/Http/Controllers/CheckoutFacebookController.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\CheckoutRoutes;
|
||||
use App\Support\LocaleConfig;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
|
||||
class CheckoutFacebookController extends Controller
|
||||
{
|
||||
private const SESSION_KEY = 'checkout_facebook_payload';
|
||||
|
||||
public function redirect(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'package_id' => ['required', 'exists:packages,id'],
|
||||
'locale' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'package_id' => (int) $validated['package_id'],
|
||||
'locale' => $validated['locale'] ?? app()->getLocale(),
|
||||
];
|
||||
|
||||
$request->session()->put(self::SESSION_KEY, $payload);
|
||||
$request->session()->put('selected_package_id', $payload['package_id']);
|
||||
|
||||
return Socialite::driver('facebook')
|
||||
->redirectUrl(route('checkout.facebook.callback'))
|
||||
->scopes(['email'])
|
||||
->fields(['name', 'email', 'first_name', 'last_name'])
|
||||
->redirect();
|
||||
}
|
||||
|
||||
public function callback(Request $request): RedirectResponse
|
||||
{
|
||||
$payload = $request->session()->get(self::SESSION_KEY, []);
|
||||
$packageId = $payload['package_id'] ?? null;
|
||||
$locale = $payload['locale'] ?? null;
|
||||
|
||||
try {
|
||||
$facebookUser = Socialite::driver('facebook')->user();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Facebook checkout login failed', ['message' => $e->getMessage()]);
|
||||
$this->flashError($request, __('checkout.facebook_error_fallback'));
|
||||
|
||||
return $this->redirectBackToWizard($packageId, $locale);
|
||||
}
|
||||
|
||||
$email = $facebookUser->getEmail();
|
||||
if (! $email) {
|
||||
$this->flashError($request, __('checkout.facebook_missing_email'));
|
||||
|
||||
return $this->redirectBackToWizard($packageId, $locale);
|
||||
}
|
||||
|
||||
$raw = $facebookUser->getRaw();
|
||||
$givenName = $raw['first_name'] ?? null;
|
||||
$familyName = $raw['last_name'] ?? null;
|
||||
$request->session()->put('checkout_facebook_profile', array_filter([
|
||||
'email' => $email,
|
||||
'name' => $facebookUser->getName(),
|
||||
'given_name' => $givenName,
|
||||
'family_name' => $familyName,
|
||||
'avatar' => $facebookUser->getAvatar(),
|
||||
'locale' => $raw['locale'] ?? null,
|
||||
]));
|
||||
|
||||
$existing = User::where('email', $email)->first();
|
||||
|
||||
if (! $existing) {
|
||||
$request->session()->put('checkout_facebook_profile', array_filter([
|
||||
'email' => $email,
|
||||
'name' => $facebookUser->getName(),
|
||||
'given_name' => $givenName,
|
||||
'family_name' => $familyName,
|
||||
'avatar' => $facebookUser->getAvatar(),
|
||||
'locale' => $raw['locale'] ?? null,
|
||||
]));
|
||||
|
||||
$request->session()->put('checkout_facebook_status', 'prefill');
|
||||
|
||||
return $this->redirectBackToWizard($packageId, $locale);
|
||||
}
|
||||
|
||||
$user = DB::transaction(function () use ($existing, $facebookUser, $email) {
|
||||
$existing->forceFill([
|
||||
'name' => $facebookUser->getName() ?: $existing->name,
|
||||
'pending_purchase' => true,
|
||||
'email_verified_at' => $existing->email_verified_at ?? now(),
|
||||
])->save();
|
||||
|
||||
if (! $existing->tenant) {
|
||||
$this->createTenantForUser($existing, $facebookUser->getName(), $email);
|
||||
}
|
||||
|
||||
return $existing->fresh();
|
||||
});
|
||||
|
||||
if (! $user->tenant) {
|
||||
$this->createTenantForUser($user, $facebookUser->getName(), $email);
|
||||
}
|
||||
|
||||
Auth::login($user, true);
|
||||
$request->session()->regenerate();
|
||||
$request->session()->forget(self::SESSION_KEY);
|
||||
$request->session()->forget('checkout_facebook_profile');
|
||||
$request->session()->put('checkout_facebook_status', 'signin');
|
||||
|
||||
if ($packageId) {
|
||||
$this->ensurePackageAttached($user, (int) $packageId);
|
||||
}
|
||||
|
||||
return $this->redirectBackToWizard($packageId, $locale);
|
||||
}
|
||||
|
||||
private function createTenantForUser(User $user, ?string $displayName, string $email): Tenant
|
||||
{
|
||||
$tenantName = trim($displayName ?: Str::before($email, '@')) ?: 'Fotospiel Tenant';
|
||||
$slugBase = Str::slug($tenantName) ?: 'tenant';
|
||||
$slug = $slugBase;
|
||||
$counter = 1;
|
||||
|
||||
while (Tenant::where('slug', $slug)->exists()) {
|
||||
$slug = $slugBase.'-'.$counter;
|
||||
$counter++;
|
||||
}
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'user_id' => $user->id,
|
||||
'name' => $tenantName,
|
||||
'slug' => $slug,
|
||||
'email' => $email,
|
||||
'contact_email' => $email,
|
||||
'is_active' => true,
|
||||
'is_suspended' => false,
|
||||
'subscription_tier' => 'free',
|
||||
'subscription_status' => 'free',
|
||||
'subscription_expires_at' => null,
|
||||
'settings' => json_encode([
|
||||
'branding' => [
|
||||
'logo_url' => null,
|
||||
'primary_color' => '#FF5A5F',
|
||||
'secondary_color' => '#FFF8F5',
|
||||
'font_family' => 'Inter, sans-serif',
|
||||
],
|
||||
'features' => [
|
||||
'photo_likes_enabled' => false,
|
||||
'event_checklist' => false,
|
||||
'custom_domain' => false,
|
||||
'advanced_analytics' => false,
|
||||
],
|
||||
'custom_domain' => null,
|
||||
'contact_email' => $email,
|
||||
'event_default_type' => 'general',
|
||||
]),
|
||||
]);
|
||||
|
||||
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
private function ensurePackageAttached(User $user, int $packageId): void
|
||||
{
|
||||
$tenant = $user->tenant;
|
||||
if (! $tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$package = Package::find($packageId);
|
||||
if (! $package) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($tenant->packages()->where('package_id', $packageId)->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant->packages()->attach($packageId, [
|
||||
'price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'expires_at' => now()->addYear(),
|
||||
'active' => $package->price <= 0,
|
||||
]);
|
||||
}
|
||||
|
||||
private function redirectBackToWizard(?int $packageId, ?string $locale = null): RedirectResponse
|
||||
{
|
||||
if ($packageId) {
|
||||
return redirect()->to(CheckoutRoutes::wizardUrl($packageId, $locale));
|
||||
}
|
||||
|
||||
$firstPackageId = Package::query()->orderBy('price')->value('id');
|
||||
if ($firstPackageId) {
|
||||
return redirect()->to(CheckoutRoutes::wizardUrl($firstPackageId, $locale));
|
||||
}
|
||||
|
||||
return redirect()->route('packages', [
|
||||
'locale' => LocaleConfig::canonicalize($locale ?? app()->getLocale()),
|
||||
]);
|
||||
}
|
||||
|
||||
private function flashError(Request $request, string $message): void
|
||||
{
|
||||
$request->session()->flash('checkout_facebook_error', $message);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ class CheckoutGoogleController extends Controller
|
||||
$request->session()->put('selected_package_id', $payload['package_id']);
|
||||
|
||||
return Socialite::driver('google')
|
||||
->redirectUrl(route('checkout.google.callback'))
|
||||
->scopes(['email', 'profile'])
|
||||
->with(['prompt' => 'select_account'])
|
||||
->redirect();
|
||||
|
||||
@@ -69,7 +69,7 @@ class LegalPageController extends Controller
|
||||
$effectiveFrom = optional($page->effective_from);
|
||||
|
||||
return Inertia::render('legal/Show', [
|
||||
'seoTitle' => $title . ' - ' . config('app.name', 'Fotospiel'),
|
||||
'seoTitle' => $title.' - '.config('app.name', 'Fotospiel'),
|
||||
'title' => $title,
|
||||
'content' => $this->convertMarkdownToHtml($bodyMarkdown),
|
||||
'effectiveFrom' => $effectiveFrom ? $effectiveFrom->toDateString() : null,
|
||||
@@ -112,11 +112,11 @@ class LegalPageController extends Controller
|
||||
'allow_unsafe_links' => false,
|
||||
]);
|
||||
|
||||
$environment->addExtension(new CommonMarkCoreExtension());
|
||||
$environment->addExtension(new TableExtension());
|
||||
$environment->addExtension(new AutolinkExtension());
|
||||
$environment->addExtension(new StrikethroughExtension());
|
||||
$environment->addExtension(new TaskListExtension());
|
||||
$environment->addExtension(new CommonMarkCoreExtension);
|
||||
$environment->addExtension(new TableExtension);
|
||||
$environment->addExtension(new AutolinkExtension);
|
||||
$environment->addExtension(new StrikethroughExtension);
|
||||
$environment->addExtension(new TaskListExtension);
|
||||
|
||||
$converter = new MarkdownConverter($environment);
|
||||
|
||||
|
||||
@@ -64,7 +64,6 @@ class MarketingController extends Controller
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|max:255',
|
||||
'message' => 'required|string|max:1000',
|
||||
'nickname' => 'present|size:0',
|
||||
]);
|
||||
|
||||
$locale = app()->getLocale();
|
||||
@@ -409,10 +408,17 @@ class MarketingController extends Controller
|
||||
|
||||
public function demo()
|
||||
{
|
||||
$joinToken = optional(Event::firstWhere('slug', 'demo-wedding-2025'))
|
||||
?->joinTokens()
|
||||
$event = Event::query()
|
||||
->where('settings->marketing_demo', true)
|
||||
->latest('id')
|
||||
->first();
|
||||
$joinToken = null;
|
||||
|
||||
if ($event) {
|
||||
$joinToken = $event->joinTokens()
|
||||
->latest('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
$demoToken = null;
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class EventPhotoArchiveController extends Controller
|
||||
abort(404, 'No approved photos available for this event.');
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
$zip = new ZipArchive;
|
||||
$tempPath = tempnam(sys_get_temp_dir(), 'fotospiel-photos-');
|
||||
|
||||
if ($tempPath === false || $zip->open($tempPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
@@ -129,4 +129,3 @@ class EventPhotoArchiveController extends Controller
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
129
app/Http/Controllers/TenantAdminFacebookController.php
Normal file
129
app/Http/Controllers/TenantAdminFacebookController.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Throwable;
|
||||
|
||||
class TenantAdminFacebookController extends Controller
|
||||
{
|
||||
public function redirect(Request $request): RedirectResponse
|
||||
{
|
||||
$returnTo = $request->query('return_to');
|
||||
if (is_string($returnTo) && $returnTo !== '') {
|
||||
$request->session()->put('tenant_oauth_return_to', $returnTo);
|
||||
}
|
||||
|
||||
return Socialite::driver('facebook')
|
||||
->redirectUrl(route('tenant.admin.facebook.callback'))
|
||||
->scopes(['email'])
|
||||
->fields(['name', 'email', 'first_name', 'last_name'])
|
||||
->redirect();
|
||||
}
|
||||
|
||||
public function callback(Request $request): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$facebookUser = Socialite::driver('facebook')->user();
|
||||
} catch (Throwable $exception) {
|
||||
Log::warning('Tenant admin Facebook sign-in failed', [
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return $this->sendBackWithError($request, 'facebook_failed', 'Unable to complete Facebook sign-in.');
|
||||
}
|
||||
|
||||
$email = $facebookUser->getEmail();
|
||||
if (! $email) {
|
||||
return $this->sendBackWithError($request, 'facebook_failed', 'Facebook account did not provide an email address.');
|
||||
}
|
||||
|
||||
/** @var User|null $user */
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
|
||||
if (! $user || ! in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
|
||||
return $this->sendBackWithError($request, 'facebook_no_match', 'No tenant admin account is linked to this Facebook address.');
|
||||
}
|
||||
|
||||
$user->forceFill([
|
||||
'name' => $facebookUser->getName() ?: $user->name,
|
||||
'email_verified_at' => $user->email_verified_at ?? now(),
|
||||
])->save();
|
||||
|
||||
Auth::login($user, true);
|
||||
$request->session()->regenerate();
|
||||
$request->session()->forget('url.intended');
|
||||
|
||||
$returnTo = $request->session()->pull('tenant_oauth_return_to');
|
||||
if (is_string($returnTo)) {
|
||||
$decoded = $this->decodeReturnTo($returnTo, $request);
|
||||
if ($decoded) {
|
||||
return redirect()->to($decoded);
|
||||
}
|
||||
}
|
||||
|
||||
$fallback = $request->session()->pull('tenant_admin.return_to');
|
||||
if (is_string($fallback) && str_starts_with($fallback, '/event-admin')) {
|
||||
return redirect()->to($fallback);
|
||||
}
|
||||
|
||||
return redirect()->to('/event-admin/dashboard');
|
||||
}
|
||||
|
||||
private function sendBackWithError(Request $request, string $code, string $message): RedirectResponse
|
||||
{
|
||||
$query = [
|
||||
'error' => $code,
|
||||
'error_description' => $message,
|
||||
];
|
||||
|
||||
if ($request->session()->has('tenant_oauth_return_to')) {
|
||||
$query['return_to'] = $request->session()->get('tenant_oauth_return_to');
|
||||
}
|
||||
|
||||
return redirect()->route('tenant.admin.login', $query);
|
||||
}
|
||||
|
||||
private function decodeReturnTo(string $encoded, Request $request): ?string
|
||||
{
|
||||
$padded = str_pad($encoded, strlen($encoded) + ((4 - (strlen($encoded) % 4)) % 4), '=');
|
||||
$normalized = strtr($padded, '-_', '+/');
|
||||
$decoded = base64_decode($normalized);
|
||||
|
||||
if (! is_string($decoded) || $decoded === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($decoded, '//')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($decoded, '/')) {
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
$targetHost = parse_url($decoded, PHP_URL_HOST);
|
||||
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
||||
|
||||
if (! $targetHost || ! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function isAllowedReturnHost(string $targetHost, string $appHost): bool
|
||||
{
|
||||
if ($targetHost === $appHost) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Str::endsWith($targetHost, '.'.$appHost);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ class TenantAdminGoogleController extends Controller
|
||||
}
|
||||
|
||||
return Socialite::driver('google')
|
||||
->redirectUrl(route('tenant.admin.google.callback'))
|
||||
->scopes(['openid', 'profile', 'email'])
|
||||
->with(['prompt' => 'select_account'])
|
||||
->redirect();
|
||||
@@ -57,6 +58,7 @@ class TenantAdminGoogleController extends Controller
|
||||
|
||||
Auth::login($user, true);
|
||||
$request->session()->regenerate();
|
||||
$request->session()->forget('url.intended');
|
||||
|
||||
$returnTo = $request->session()->pull('tenant_oauth_return_to');
|
||||
if (is_string($returnTo)) {
|
||||
@@ -66,7 +68,12 @@ class TenantAdminGoogleController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->intended('/event-admin/dashboard');
|
||||
$fallback = $request->session()->pull('tenant_admin.return_to');
|
||||
if (is_string($fallback) && str_starts_with($fallback, '/event-admin')) {
|
||||
return redirect()->to($fallback);
|
||||
}
|
||||
|
||||
return redirect()->to('/event-admin/dashboard');
|
||||
}
|
||||
|
||||
private function sendBackWithError(Request $request, string $code, string $message): RedirectResponse
|
||||
@@ -93,13 +100,30 @@ class TenantAdminGoogleController extends Controller
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($decoded, '//')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($decoded, '/')) {
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
$targetHost = parse_url($decoded, PHP_URL_HOST);
|
||||
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
||||
|
||||
if ($targetHost && $appHost && ! Str::endsWith($targetHost, $appHost)) {
|
||||
if (! $targetHost || ! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function isAllowedReturnHost(string $targetHost, string $appHost): bool
|
||||
{
|
||||
if ($targetHost === $appHost) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Str::endsWith($targetHost, '.'.$appHost);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,11 +118,18 @@ class ContentSecurityPolicy
|
||||
$styleSources[] = 'data:';
|
||||
$connectSources[] = 'https:';
|
||||
$fontSources[] = 'https:';
|
||||
$styleElemSources = array_values(array_filter(
|
||||
$styleSources,
|
||||
static fn (string $source): bool => ! str_starts_with($source, "'nonce-")
|
||||
));
|
||||
$styleElemSources = array_unique(array_merge($styleElemSources, ["'unsafe-inline'"]));
|
||||
|
||||
$directives = [
|
||||
'default-src' => ["'self'"],
|
||||
'script-src' => array_unique($scriptSources),
|
||||
'style-src' => array_unique($styleSources),
|
||||
'style-src-elem' => $styleElemSources,
|
||||
'style-src-attr' => ["'unsafe-inline'"],
|
||||
'img-src' => array_unique($imgSources),
|
||||
'font-src' => array_unique($fontSources),
|
||||
'connect-src' => array_unique($connectSources),
|
||||
|
||||
66
app/Http/Middleware/EnsureSupportToken.php
Normal file
66
app/Http/Middleware/EnsureSupportToken.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\ApiError;
|
||||
use Closure;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureSupportToken
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): JsonResponse|Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user) {
|
||||
return $this->unauthorizedResponse('Unauthenticated request.');
|
||||
}
|
||||
|
||||
$accessToken = $user->currentAccessToken();
|
||||
|
||||
if (! $accessToken instanceof PersonalAccessToken) {
|
||||
return $this->unauthorizedResponse('Missing personal access token context.');
|
||||
}
|
||||
|
||||
if (! $user->isSuperAdmin()) {
|
||||
return $this->forbiddenResponse('Only super administrators may access support APIs.');
|
||||
}
|
||||
|
||||
if (! $accessToken->can('support-admin') && ! $accessToken->can('super-admin')) {
|
||||
return $this->forbiddenResponse('Access token does not include the support-admin ability.');
|
||||
}
|
||||
|
||||
$request->attributes->set('support_token_id', $accessToken->id);
|
||||
|
||||
Auth::shouldUse('sanctum');
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function unauthorizedResponse(string $message): JsonResponse
|
||||
{
|
||||
return ApiError::response(
|
||||
'unauthenticated',
|
||||
'Unauthenticated',
|
||||
$message,
|
||||
Response::HTTP_UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
private function forbiddenResponse(string $message): JsonResponse
|
||||
{
|
||||
return ApiError::response(
|
||||
'support_forbidden',
|
||||
'Forbidden',
|
||||
$message,
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Support\LocaleConfig;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
use Spatie\Honeypot\Honeypot;
|
||||
|
||||
class HandleInertiaRequests extends Middleware
|
||||
{
|
||||
@@ -67,6 +68,7 @@ class HandleInertiaRequests extends Middleware
|
||||
'error' => fn () => $request->session()->get('error'),
|
||||
'verification' => fn () => $request->session()->get('verification'),
|
||||
],
|
||||
'honeypot' => fn () => new Honeypot(config('honeypot')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ class SetLocale
|
||||
$sessionLocale = Session::get('locale', 'de');
|
||||
|
||||
// Fallback to Accept-Language header if no session
|
||||
if (!in_array($sessionLocale, $supportedLocales)) {
|
||||
if (! in_array($sessionLocale, $supportedLocales)) {
|
||||
$acceptLanguage = $request->header('Accept-Language', 'de');
|
||||
$localeFromHeader = substr($acceptLanguage, 0, 2);
|
||||
$sessionLocale = in_array($localeFromHeader, $supportedLocales) ? $localeFromHeader : 'de';
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\LocaleConfig;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use App\Support\LocaleConfig;
|
||||
|
||||
class SetLocaleFromRequest
|
||||
{
|
||||
|
||||
@@ -19,4 +19,3 @@ class SetLocaleFromUser
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,8 +57,3 @@ class ProfileUpdateRequest extends FormRequest
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Resources;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SupportBlogPostResourceRequest extends SupportResourceFormRequest
|
||||
{
|
||||
public static function rulesFor(string $action, ?Model $model = null): array
|
||||
{
|
||||
$postId = $model?->getKey();
|
||||
|
||||
$rules = [
|
||||
'blog_category_id' => ['sometimes', 'integer', 'exists:blog_categories,id'],
|
||||
'slug' => [
|
||||
'sometimes',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('blog_posts', 'slug')->ignore($postId),
|
||||
],
|
||||
'banner' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'published_at' => ['sometimes', 'nullable', 'date'],
|
||||
'is_published' => ['sometimes', 'boolean'],
|
||||
'title' => ['sometimes', 'array'],
|
||||
'title.de' => ['required_with:title', 'string', 'max:255'],
|
||||
'title.en' => ['nullable', 'string', 'max:255'],
|
||||
'content' => ['sometimes', 'array'],
|
||||
'content.de' => ['required_with:content', 'string'],
|
||||
'content.en' => ['nullable', 'string'],
|
||||
'excerpt' => ['sometimes', 'array'],
|
||||
'excerpt.de' => ['nullable', 'string'],
|
||||
'excerpt.en' => ['nullable', 'string'],
|
||||
'meta_title' => ['sometimes', 'array'],
|
||||
'meta_title.de' => ['nullable', 'string', 'max:255'],
|
||||
'meta_title.en' => ['nullable', 'string', 'max:255'],
|
||||
'meta_description' => ['sometimes', 'array'],
|
||||
'meta_description.de' => ['nullable', 'string'],
|
||||
'meta_description.en' => ['nullable', 'string'],
|
||||
'translations' => ['sometimes', 'array'],
|
||||
];
|
||||
|
||||
if ($action === 'create') {
|
||||
$rules['blog_category_id'] = ['required', 'integer', 'exists:blog_categories,id'];
|
||||
$rules['slug'] = ['required', 'string', 'max:255', Rule::unique('blog_posts', 'slug')];
|
||||
$rules['title'] = ['required', 'array'];
|
||||
$rules['title.de'] = ['required', 'string', 'max:255'];
|
||||
$rules['content'] = ['required', 'array'];
|
||||
$rules['content.de'] = ['required', 'string'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
public static function allowedFields(string $action): array
|
||||
{
|
||||
return [
|
||||
'blog_category_id',
|
||||
'slug',
|
||||
'banner',
|
||||
'published_at',
|
||||
'is_published',
|
||||
'title',
|
||||
'content',
|
||||
'excerpt',
|
||||
'meta_title',
|
||||
'meta_description',
|
||||
'translations',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Resources;
|
||||
|
||||
use App\Enums\DataExportScope;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SupportDataExportResourceRequest extends SupportResourceFormRequest
|
||||
{
|
||||
public static function rulesFor(string $action, ?Model $model = null): array
|
||||
{
|
||||
$scopeValues = array_map(static fn (DataExportScope $scope): string => $scope->value, DataExportScope::cases());
|
||||
|
||||
return [
|
||||
'scope' => ['required', 'string', Rule::in($scopeValues)],
|
||||
'tenant_id' => ['required', 'integer', 'exists:tenants,id'],
|
||||
'event_id' => ['nullable', 'integer', 'exists:events,id', 'required_if:scope,event'],
|
||||
'include_media' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function allowedFields(string $action): array
|
||||
{
|
||||
return [
|
||||
'scope',
|
||||
'tenant_id',
|
||||
'event_id',
|
||||
'include_media',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Resources;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SupportEmotionResourceRequest extends SupportResourceFormRequest
|
||||
{
|
||||
public static function rulesFor(string $action, ?Model $model = null): array
|
||||
{
|
||||
$rules = [
|
||||
'name' => ['sometimes', 'array'],
|
||||
'name.de' => ['required_with:name', 'string', 'max:255'],
|
||||
'name.en' => ['required_with:name', 'string', 'max:255'],
|
||||
'description' => ['sometimes', 'array'],
|
||||
'description.de' => ['nullable', 'string'],
|
||||
'description.en' => ['nullable', 'string'],
|
||||
'icon' => ['sometimes', 'nullable', 'string', 'max:50'],
|
||||
'color' => ['sometimes', 'nullable', 'string', 'max:7'],
|
||||
'sort_order' => ['sometimes', 'integer', 'min:0'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
'tenant_id' => ['sometimes', 'nullable', 'integer', 'exists:tenants,id'],
|
||||
];
|
||||
|
||||
if ($action === 'create') {
|
||||
$rules['name'] = ['required', 'array'];
|
||||
$rules['name.de'] = ['required', 'string', 'max:255'];
|
||||
$rules['name.en'] = ['required', 'string', 'max:255'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
public static function allowedFields(string $action): array
|
||||
{
|
||||
return [
|
||||
'name',
|
||||
'description',
|
||||
'icon',
|
||||
'color',
|
||||
'sort_order',
|
||||
'is_active',
|
||||
'tenant_id',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Resources;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SupportEventResourceRequest extends SupportResourceFormRequest
|
||||
{
|
||||
public static function rulesFor(string $action, ?Model $model = null): array
|
||||
{
|
||||
$eventId = $model?->getKey();
|
||||
|
||||
$rules = [
|
||||
'name' => ['sometimes', 'array'],
|
||||
'name.de' => ['required_with:name', 'string', 'max:255'],
|
||||
'name.en' => ['nullable', 'string', 'max:255'],
|
||||
'description' => ['sometimes', 'array'],
|
||||
'description.de' => ['nullable', 'string'],
|
||||
'description.en' => ['nullable', 'string'],
|
||||
'slug' => [
|
||||
'sometimes',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('events', 'slug')->ignore($eventId),
|
||||
],
|
||||
'date' => ['sometimes', 'date'],
|
||||
'location' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'max_participants' => ['sometimes', 'nullable', 'integer', 'min:0'],
|
||||
'event_type_id' => ['sometimes', 'nullable', 'integer', 'exists:event_types,id'],
|
||||
'default_locale' => ['sometimes', 'string', 'max:5'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
'status' => ['sometimes', 'string', Rule::in(['draft', 'published', 'archived'])],
|
||||
'settings' => ['sometimes', 'array'],
|
||||
'join_link_enabled' => ['sometimes', 'boolean'],
|
||||
'photo_upload_enabled' => ['sometimes', 'boolean'],
|
||||
'task_checklist_enabled' => ['sometimes', 'boolean'],
|
||||
];
|
||||
|
||||
if ($action === 'create') {
|
||||
$rules['name'] = ['required', 'array'];
|
||||
$rules['name.de'] = ['required', 'string', 'max:255'];
|
||||
$rules['slug'] = [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('events', 'slug'),
|
||||
];
|
||||
$rules['date'] = ['required', 'date'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
public static function allowedFields(string $action): array
|
||||
{
|
||||
return [
|
||||
'name',
|
||||
'description',
|
||||
'slug',
|
||||
'date',
|
||||
'location',
|
||||
'max_participants',
|
||||
'event_type_id',
|
||||
'default_locale',
|
||||
'is_active',
|
||||
'status',
|
||||
'settings',
|
||||
'join_link_enabled',
|
||||
'photo_upload_enabled',
|
||||
'task_checklist_enabled',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Resources;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SupportPhotoResourceRequest extends SupportResourceFormRequest
|
||||
{
|
||||
public static function rulesFor(string $action, ?Model $model = null): array
|
||||
{
|
||||
return [
|
||||
'status' => ['sometimes', 'string', Rule::in(['pending', 'approved', 'rejected', 'hidden'])],
|
||||
'moderation_notes' => ['nullable', 'required_if:status,rejected', 'string', 'max:1000'],
|
||||
'is_featured' => ['sometimes', 'boolean'],
|
||||
'emotion_id' => ['sometimes', 'nullable', 'integer', 'exists:emotions,id'],
|
||||
'task_id' => ['sometimes', 'nullable', 'integer', 'exists:tasks,id'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function allowedFields(string $action): array
|
||||
{
|
||||
return [
|
||||
'status',
|
||||
'moderation_notes',
|
||||
'is_featured',
|
||||
'emotion_id',
|
||||
'task_id',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Resources;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SupportPhotoboothSettingResourceRequest extends SupportResourceFormRequest
|
||||
{
|
||||
public static function rulesFor(string $action, ?Model $model = null): array
|
||||
{
|
||||
return [
|
||||
'ftp_port' => ['sometimes', 'integer', 'min:1', 'max:65535'],
|
||||
'rate_limit_per_minute' => ['sometimes', 'integer', 'min:1', 'max:200'],
|
||||
'expiry_grace_days' => ['sometimes', 'integer', 'min:0', 'max:14'],
|
||||
'require_ftps' => ['sometimes', 'boolean'],
|
||||
'allowed_ip_ranges' => ['sometimes', 'array'],
|
||||
'control_service_base_url' => ['sometimes', 'nullable', 'string', 'max:191'],
|
||||
'control_service_token_identifier' => ['sometimes', 'nullable', 'string', 'max:191'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function allowedFields(string $action): array
|
||||
{
|
||||
return [
|
||||
'ftp_port',
|
||||
'rate_limit_per_minute',
|
||||
'expiry_grace_days',
|
||||
'require_ftps',
|
||||
'allowed_ip_ranges',
|
||||
'control_service_base_url',
|
||||
'control_service_token_identifier',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Resources;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
abstract class SupportResourceFormRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function rulesFor(string $action, ?Model $model = null): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function allowedFields(string $action): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return static::rulesFor('update', null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Resources;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SupportTaskResourceRequest extends SupportResourceFormRequest
|
||||
{
|
||||
public static function rulesFor(string $action, ?Model $model = null): array
|
||||
{
|
||||
$rules = [
|
||||
'emotion_id' => ['sometimes', 'integer', 'exists:emotions,id'],
|
||||
'event_type_id' => ['sometimes', 'nullable', 'integer', 'exists:event_types,id'],
|
||||
'title' => ['sometimes', 'array'],
|
||||
'title.de' => ['required_with:title', 'string', 'max:255'],
|
||||
'title.en' => ['required_with:title', 'string', 'max:255'],
|
||||
'description' => ['sometimes', 'array'],
|
||||
'description.de' => ['nullable', 'string'],
|
||||
'description.en' => ['nullable', 'string'],
|
||||
'example_text' => ['sometimes', 'array'],
|
||||
'example_text.de' => ['nullable', 'string'],
|
||||
'example_text.en' => ['nullable', 'string'],
|
||||
'difficulty' => ['sometimes', 'string', Rule::in(['easy', 'medium', 'hard'])],
|
||||
'sort_order' => ['sometimes', 'integer', 'min:0'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
];
|
||||
|
||||
if ($action === 'create') {
|
||||
$rules['emotion_id'] = ['required', 'integer', 'exists:emotions,id'];
|
||||
$rules['title'] = ['required', 'array'];
|
||||
$rules['title.de'] = ['required', 'string', 'max:255'];
|
||||
$rules['title.en'] = ['required', 'string', 'max:255'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
public static function allowedFields(string $action): array
|
||||
{
|
||||
return [
|
||||
'emotion_id',
|
||||
'event_type_id',
|
||||
'title',
|
||||
'description',
|
||||
'example_text',
|
||||
'difficulty',
|
||||
'sort_order',
|
||||
'is_active',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Resources;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SupportTenantFeedbackResourceRequest extends SupportResourceFormRequest
|
||||
{
|
||||
public static function rulesFor(string $action, ?Model $model = null): array
|
||||
{
|
||||
return [
|
||||
'status' => [
|
||||
'sometimes',
|
||||
'string',
|
||||
Rule::in(['pending', 'resolved', 'hidden', 'deleted']),
|
||||
],
|
||||
'moderation_notes' => ['sometimes', 'nullable', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function allowedFields(string $action): array
|
||||
{
|
||||
return [
|
||||
'status',
|
||||
'moderation_notes',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Resources;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SupportTenantResourceRequest extends SupportResourceFormRequest
|
||||
{
|
||||
public static function rulesFor(string $action, ?Model $model = null): array
|
||||
{
|
||||
$tenantId = $model?->getKey();
|
||||
|
||||
return [
|
||||
'slug' => [
|
||||
'sometimes',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('tenants', 'slug')->ignore($tenantId),
|
||||
],
|
||||
'contact_email' => ['sometimes', 'email', 'max:255'],
|
||||
'paddle_customer_id' => ['sometimes', 'nullable', 'string', 'max:191'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
'is_suspended' => ['sometimes', 'boolean'],
|
||||
'features' => ['sometimes', 'array'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function allowedFields(string $action): array
|
||||
{
|
||||
return [
|
||||
'slug',
|
||||
'contact_email',
|
||||
'paddle_customer_id',
|
||||
'is_active',
|
||||
'is_suspended',
|
||||
'features',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Resources;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SupportUserResourceRequest extends SupportResourceFormRequest
|
||||
{
|
||||
public static function rulesFor(string $action, ?Model $model = null): array
|
||||
{
|
||||
$userId = $model?->getKey();
|
||||
|
||||
return [
|
||||
'first_name' => ['sometimes', 'string', 'max:255'],
|
||||
'last_name' => ['sometimes', 'string', 'max:255'],
|
||||
'username' => [
|
||||
'sometimes',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('users', 'username')->ignore($userId),
|
||||
],
|
||||
'email' => [
|
||||
'sometimes',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique('users', 'email')->ignore($userId),
|
||||
],
|
||||
'address' => ['sometimes', 'string', 'max:1000'],
|
||||
'phone' => ['sometimes', 'string', 'max:50'],
|
||||
'preferred_locale' => ['sometimes', 'string', 'max:10'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function allowedFields(string $action): array
|
||||
{
|
||||
return [
|
||||
'first_name',
|
||||
'last_name',
|
||||
'username',
|
||||
'email',
|
||||
'address',
|
||||
'phone',
|
||||
'preferred_locale',
|
||||
];
|
||||
}
|
||||
}
|
||||
36
app/Http/Requests/Support/SupportGuestPolicyRequest.php
Normal file
36
app/Http/Requests/Support/SupportGuestPolicyRequest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SupportGuestPolicyRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'guest_downloads_enabled' => ['sometimes', 'boolean'],
|
||||
'guest_sharing_enabled' => ['sometimes', 'boolean'],
|
||||
'guest_upload_visibility' => ['sometimes', 'string'],
|
||||
'per_device_upload_limit' => ['sometimes', 'integer', 'min:0'],
|
||||
'join_token_failure_limit' => ['sometimes', 'integer', 'min:1'],
|
||||
'join_token_failure_decay_minutes' => ['sometimes', 'integer', 'min:1'],
|
||||
'join_token_access_limit' => ['sometimes', 'integer', 'min:0'],
|
||||
'join_token_access_decay_minutes' => ['sometimes', 'integer', 'min:1'],
|
||||
'join_token_download_limit' => ['sometimes', 'integer', 'min:0'],
|
||||
'join_token_download_decay_minutes' => ['sometimes', 'integer', 'min:1'],
|
||||
'join_token_ttl_hours' => ['sometimes', 'integer', 'min:0'],
|
||||
'share_link_ttl_hours' => ['sometimes', 'integer', 'min:1'],
|
||||
'guest_notification_ttl_hours' => ['nullable', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Http/Requests/Support/SupportResourceRequest.php
Normal file
24
app/Http/Requests/Support/SupportResourceRequest.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SupportResourceRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'data' => ['required', 'array'],
|
||||
];
|
||||
}
|
||||
}
|
||||
52
app/Http/Requests/Support/SupportTokenRequest.php
Normal file
52
app/Http/Requests/Support/SupportTokenRequest.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SupportTokenRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'login' => ['required', 'string'],
|
||||
'password' => ['required', 'string'],
|
||||
'abilities' => ['sometimes', 'array'],
|
||||
'abilities.*' => ['string'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{email?: string, username?: string, password: string, abilities?: array<int, string>}
|
||||
*/
|
||||
public function credentials(): array
|
||||
{
|
||||
$login = $this->string('login')->trim()->value();
|
||||
|
||||
$credentials = [
|
||||
'password' => $this->string('password')->value(),
|
||||
];
|
||||
|
||||
if (filter_var($login, FILTER_VALIDATE_EMAIL)) {
|
||||
$credentials['email'] = $login;
|
||||
} else {
|
||||
$credentials['username'] = $login;
|
||||
}
|
||||
|
||||
$abilities = $this->input('abilities');
|
||||
if (is_array($abilities) && $abilities !== []) {
|
||||
$credentials['abilities'] = array_values(array_filter($abilities, 'is_string'));
|
||||
}
|
||||
|
||||
return $credentials;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SupportWatermarkSettingsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'asset' => ['sometimes', 'string'],
|
||||
'position' => ['sometimes', 'string'],
|
||||
'opacity' => ['sometimes', 'numeric', 'min:0', 'max:1'],
|
||||
'scale' => ['sometimes', 'numeric', 'min:0.05', 'max:1'],
|
||||
'padding' => ['sometimes', 'integer', 'min:0'],
|
||||
'offset_x' => ['sometimes', 'integer', 'min:-500', 'max:500'],
|
||||
'offset_y' => ['sometimes', 'integer', 'min:-500', 'max:500'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Tenant;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SupportTenantAddPackageRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'package_id' => ['required', 'integer'],
|
||||
'expires_at' => ['nullable', 'date'],
|
||||
'reason' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Tenant;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SupportTenantScheduleDeletionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'pending_deletion_at' => ['required', 'date', 'after:now'],
|
||||
'send_warning' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Tenant;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SupportTenantSetGracePeriodRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'grace_period_ends_at' => ['required', 'date', 'after_or_equal:now'],
|
||||
'note' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Tenant;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SupportTenantUpdateLimitsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'max_photos_per_event' => ['required', 'integer', 'min:0'],
|
||||
'max_storage_mb' => ['required', 'integer', 'min:0'],
|
||||
'note' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Support\Tenant;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SupportTenantUpdateSubscriptionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'subscription_expires_at' => ['nullable', 'date'],
|
||||
'note' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class PhotoStoreRequest extends FormRequest
|
||||
{
|
||||
@@ -59,4 +58,4 @@ class PhotoStoreRequest extends FormRequest
|
||||
'tags' => $this->tags ? explode(',', $this->tags) : [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,4 +88,3 @@ class SettingsStoreRequest extends FormRequest
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,4 +18,4 @@ class CreditLedgerResource extends JsonResource
|
||||
'created_at' => $this->created_at->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ class EmotionResource extends JsonResource
|
||||
}
|
||||
|
||||
$first = reset($value);
|
||||
|
||||
return $first !== false ? (string) $first : $fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,4 +21,4 @@ class EventPurchaseResource extends JsonResource
|
||||
'created_at' => $this->created_at->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ class ArchiveEventMediaAssets implements ShouldQueue
|
||||
|
||||
if (! $event) {
|
||||
Log::warning('Archive job aborted: event missing', ['event_id' => $this->eventId]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -38,6 +39,7 @@ class ArchiveEventMediaAssets implements ShouldQueue
|
||||
|
||||
if (! $archiveDisk) {
|
||||
Log::warning('Archive job aborted: no archive disk configured', ['event_id' => $event->id]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -69,12 +71,44 @@ class ArchiveEventMediaAssets implements ShouldQueue
|
||||
|
||||
Storage::disk($archiveDisk)->put($archivePath, $stream);
|
||||
|
||||
$checksumMeta = null;
|
||||
$archiveChecksum = null;
|
||||
if ($this->checksumValidationEnabled()) {
|
||||
$archiveChecksum = $this->computeChecksum($archiveDisk, $archivePath);
|
||||
if (! $archiveChecksum) {
|
||||
throw new \RuntimeException('Archive checksum unavailable');
|
||||
}
|
||||
|
||||
$expectedChecksum = $asset->checksum;
|
||||
if ($expectedChecksum) {
|
||||
if (! hash_equals($expectedChecksum, $archiveChecksum)) {
|
||||
$this->handleChecksumMismatch($asset, $expectedChecksum, $archiveChecksum, $sourceDisk, $archiveDisk);
|
||||
$this->deleteArchiveCopy($archiveDisk, $archivePath);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$checksumMeta = [
|
||||
'checksum_status' => 'verified',
|
||||
'checksum_verified_at' => now()->toIso8601String(),
|
||||
];
|
||||
} else {
|
||||
$asset->checksum = $archiveChecksum;
|
||||
$checksumMeta = [
|
||||
'checksum_status' => 'seeded',
|
||||
'checksum_verified_at' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$asset->fill([
|
||||
'disk' => $archiveDisk,
|
||||
'media_storage_target_id' => $archiveTargetId,
|
||||
'status' => 'archived',
|
||||
'archived_at' => now(),
|
||||
'error_message' => null,
|
||||
'checksum' => $asset->checksum,
|
||||
'meta' => $this->mergeMeta($asset->meta, $checksumMeta),
|
||||
])->save();
|
||||
|
||||
if ($this->deleteSource) {
|
||||
@@ -100,4 +134,92 @@ class ArchiveEventMediaAssets implements ShouldQueue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function checksumValidationEnabled(): bool
|
||||
{
|
||||
return (bool) config('storage-monitor.checksum_validation.enabled', true);
|
||||
}
|
||||
|
||||
private function computeChecksum(string $disk, string $path): ?string
|
||||
{
|
||||
try {
|
||||
$stream = Storage::disk($disk)->readStream($path);
|
||||
} catch (\Throwable $e) {
|
||||
Log::channel('storage-jobs')->warning('Failed to open stream for checksum', [
|
||||
'disk' => $disk,
|
||||
'path' => $path,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $stream) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$context = hash_init('sha256');
|
||||
$ok = hash_update_stream($context, $stream);
|
||||
if ($ok === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return hash_final($context);
|
||||
} finally {
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function handleChecksumMismatch(
|
||||
EventMediaAsset $asset,
|
||||
string $expectedChecksum,
|
||||
string $actualChecksum,
|
||||
string $sourceDisk,
|
||||
string $archiveDisk,
|
||||
): void {
|
||||
Log::channel('storage-jobs')->alert('Checksum mismatch detected during archive', [
|
||||
'asset_id' => $asset->id,
|
||||
'event_id' => $asset->event_id,
|
||||
'source_disk' => $sourceDisk,
|
||||
'archive_disk' => $archiveDisk,
|
||||
'expected_checksum' => $expectedChecksum,
|
||||
'actual_checksum' => $actualChecksum,
|
||||
]);
|
||||
|
||||
$asset->update([
|
||||
'status' => 'failed',
|
||||
'error_message' => 'checksum_mismatch',
|
||||
'meta' => $this->mergeMeta($asset->meta, [
|
||||
'checksum_status' => 'mismatch',
|
||||
'checksum_verified_at' => now()->toIso8601String(),
|
||||
'checksum_expected' => $expectedChecksum,
|
||||
'checksum_actual' => $actualChecksum,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
private function deleteArchiveCopy(string $archiveDisk, string $path): void
|
||||
{
|
||||
try {
|
||||
Storage::disk($archiveDisk)->delete($path);
|
||||
} catch (\Throwable $e) {
|
||||
Log::channel('storage-jobs')->warning('Failed to clean up archive copy after checksum mismatch', [
|
||||
'disk' => $archiveDisk,
|
||||
'path' => $path,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function mergeMeta(?array $meta, ?array $updates): ?array
|
||||
{
|
||||
if (! $updates) {
|
||||
return $meta;
|
||||
}
|
||||
|
||||
return array_merge($meta ?? [], $updates);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
namespace App\Jobs\Concerns;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantNotificationLog;
|
||||
use App\Models\TenantNotificationReceipt;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Packages\TenantNotificationLogger;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
trait LogsTenantNotifications
|
||||
{
|
||||
|
||||
@@ -26,7 +26,7 @@ class AbandonedCheckout extends Mailable
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: __('emails.abandoned_checkout.subject_' . $this->timing, [
|
||||
subject: __('emails.abandoned_checkout.subject_'.$this->timing, [
|
||||
'package' => $this->localizedPackageName(),
|
||||
]),
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user