Compare commits
127 Commits
b9708d5174
...
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 |
@@ -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 \
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -109,8 +109,9 @@ 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(),
|
||||
@@ -278,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,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,22 +3,50 @@
|
||||
namespace App\Filament\SuperAdmin\Pages\Auth;
|
||||
|
||||
use Filament\Auth\Pages\EditProfile as BaseEditProfile;
|
||||
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([
|
||||
Section::make('Profile')
|
||||
->schema([
|
||||
$this->getNameFormComponent(),
|
||||
$this->getEmailFormComponent(),
|
||||
@@ -33,9 +61,20 @@ class EditProfile extends BaseEditProfile
|
||||
])
|
||||
->default('de')
|
||||
->required(),
|
||||
])
|
||||
->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)
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
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')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -71,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) {
|
||||
@@ -102,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,12 +63,11 @@ class BlogPost extends Model
|
||||
}
|
||||
|
||||
$path = ltrim($this->banner, '/');
|
||||
if (str_starts_with($path, 'storage/')) {
|
||||
$path = substr($path, strlen('storage/'));
|
||||
}
|
||||
|
||||
return \URL::temporarySignedRoute(
|
||||
'api.v1.branding.asset',
|
||||
now()->addMinutes(30),
|
||||
['path' => $path]
|
||||
);
|
||||
return \Storage::disk('public')->url($path);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,11 @@ class TenantPackage extends Model
|
||||
$tenantPackage->purchased_at = now();
|
||||
}
|
||||
|
||||
if ($tenantPackage->price === null) {
|
||||
$package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id);
|
||||
$tenantPackage->price = $package?->price ?? 0;
|
||||
}
|
||||
|
||||
$package = $tenantPackage->package;
|
||||
|
||||
if ($package && $package->isReseller()) {
|
||||
|
||||
@@ -47,6 +47,7 @@ use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Inertia\Inertia;
|
||||
use Livewire\Livewire;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -200,6 +201,11 @@ class AppServiceProvider extends ServiceProvider
|
||||
];
|
||||
});
|
||||
|
||||
Livewire::component(
|
||||
'support-api-token-manager',
|
||||
\App\Filament\SuperAdmin\Widgets\SupportApiTokenManager::class
|
||||
);
|
||||
|
||||
RateLimiter::for('gift-resend', function (Request $request) {
|
||||
$code = strtoupper((string) $request->input('code'));
|
||||
$ip = $request->ip() ?? 'unknown';
|
||||
|
||||
@@ -81,7 +81,7 @@ class SuperAdminPanelProvider extends PanelProvider
|
||||
/*->plugin(
|
||||
BlogPlugin::make()
|
||||
)*/
|
||||
->profile()
|
||||
->profile(\App\Filament\SuperAdmin\Pages\Auth\EditProfile::class, isSimple: false)
|
||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
|
||||
->widgets([
|
||||
Widgets\AccountWidget::class,
|
||||
|
||||
@@ -93,11 +93,15 @@ class SuperAdminAuditLogger
|
||||
return $panel->getId() === 'superadmin';
|
||||
}
|
||||
|
||||
if (request()->is('super-admin*') || request()->is('api/v1/support*')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (app()->runningInConsole()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return request()->is('super-admin*');
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,6 +40,48 @@ class DokployClient
|
||||
}, 30);
|
||||
}
|
||||
|
||||
public function projects(): array
|
||||
{
|
||||
return $this->cached($this->projectsCacheKey(), function () {
|
||||
$projects = $this->get('/project.all');
|
||||
|
||||
return is_array($projects) ? $projects : [];
|
||||
}, 60);
|
||||
}
|
||||
|
||||
public function project(string $projectId): array
|
||||
{
|
||||
return $this->cached($this->projectCacheKey($projectId), function () use ($projectId) {
|
||||
$project = $this->get('/project.one', [
|
||||
'projectId' => $projectId,
|
||||
]);
|
||||
|
||||
return is_array($project) ? $project : [];
|
||||
}, 60);
|
||||
}
|
||||
|
||||
public function findProject(string $projectIdOrName): ?array
|
||||
{
|
||||
$projects = $this->projects();
|
||||
|
||||
foreach ($projects as $project) {
|
||||
if (Arr::get($project, 'projectId') === $projectIdOrName) {
|
||||
return $project;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($projects as $project) {
|
||||
if (
|
||||
Arr::get($project, 'name') === $projectIdOrName
|
||||
|| Arr::get($project, 'projectName') === $projectIdOrName
|
||||
) {
|
||||
return $project;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function recentDeployments(string $applicationId, int $limit = 5): array
|
||||
{
|
||||
return $this->cached($this->deploymentCacheKey($applicationId), function () use ($applicationId, $limit) {
|
||||
@@ -321,6 +363,16 @@ class DokployClient
|
||||
return "dokploy.compose.deployments.{$composeId}";
|
||||
}
|
||||
|
||||
protected function projectsCacheKey(): string
|
||||
{
|
||||
return 'dokploy.projects';
|
||||
}
|
||||
|
||||
protected function projectCacheKey(string $projectId): string
|
||||
{
|
||||
return "dokploy.project.{$projectId}";
|
||||
}
|
||||
|
||||
protected function forgetApplicationCaches(string $applicationId): void
|
||||
{
|
||||
Cache::forget($this->applicationCacheKey($applicationId));
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Services;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventJoinToken;
|
||||
use App\Models\EventJoinTokenEvent;
|
||||
use App\Models\GuestPolicySetting;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -63,7 +64,7 @@ class EventJoinTokenService
|
||||
return $joinToken;
|
||||
}
|
||||
|
||||
public function incrementUsage(EventJoinToken $joinToken): void
|
||||
public function incrementUsage(EventJoinToken $joinToken, ?string $deviceId = null, ?string $ipAddress = null): void
|
||||
{
|
||||
$joinToken->increment('usage_count');
|
||||
|
||||
@@ -78,6 +79,12 @@ class EventJoinTokenService
|
||||
$eventPackage = $limitEvaluator->resolveEventPackageForPhotoUpload($event->tenant, $event->id, $event);
|
||||
|
||||
if ($eventPackage && $eventPackage->package?->max_guests !== null) {
|
||||
$normalizedDeviceId = $this->normalizeDeviceId($deviceId);
|
||||
$normalizedIp = is_string($ipAddress) && trim($ipAddress) !== '' ? $ipAddress : null;
|
||||
if (! $this->shouldCountGuest($event->id, $normalizedDeviceId, $normalizedIp)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$previous = (int) $eventPackage->used_guests;
|
||||
$eventPackage->increment('used_guests');
|
||||
$eventPackage->refresh();
|
||||
@@ -87,6 +94,28 @@ class EventJoinTokenService
|
||||
}
|
||||
}
|
||||
|
||||
public function hasSeenGuest(int $eventId, ?string $deviceId, ?string $ipAddress): bool
|
||||
{
|
||||
$normalizedDeviceId = $this->normalizeDeviceId($deviceId);
|
||||
$normalizedIp = is_string($ipAddress) && trim($ipAddress) !== '' ? $ipAddress : null;
|
||||
|
||||
if (! $normalizedDeviceId && ! $normalizedIp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$query = EventJoinTokenEvent::query()
|
||||
->where('event_id', $eventId)
|
||||
->where('event_type', 'access_granted');
|
||||
|
||||
if ($normalizedDeviceId) {
|
||||
$query->where('device_id', $normalizedDeviceId);
|
||||
} else {
|
||||
$query->where('ip_address', $normalizedIp);
|
||||
}
|
||||
|
||||
return $query->exists();
|
||||
}
|
||||
|
||||
public function findToken(string $token, bool $includeInactive = false): ?EventJoinToken
|
||||
{
|
||||
$hash = $this->hashToken($token);
|
||||
@@ -132,4 +161,35 @@ class EventJoinTokenService
|
||||
{
|
||||
return hash('sha256', $token);
|
||||
}
|
||||
|
||||
private function normalizeDeviceId(?string $deviceId): ?string
|
||||
{
|
||||
if (! is_string($deviceId) || trim($deviceId) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cleaned = preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId) ?? '';
|
||||
$cleaned = substr($cleaned, 0, 64);
|
||||
|
||||
return $cleaned !== '' ? $cleaned : null;
|
||||
}
|
||||
|
||||
private function shouldCountGuest(int $eventId, ?string $deviceId, ?string $ipAddress): bool
|
||||
{
|
||||
if (! $deviceId && ! $ipAddress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$query = EventJoinTokenEvent::query()
|
||||
->where('event_id', $eventId)
|
||||
->where('event_type', 'access_granted');
|
||||
|
||||
if ($deviceId) {
|
||||
$query->where('device_id', $deviceId);
|
||||
} else {
|
||||
$query->where('ip_address', $ipAddress);
|
||||
}
|
||||
|
||||
return $query->count() <= 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Services\Help;
|
||||
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -70,6 +71,8 @@ class HelpSyncService
|
||||
}
|
||||
}
|
||||
|
||||
$articles = $this->hydrateRelatedTitles($articles);
|
||||
|
||||
$disk = config('help.disk');
|
||||
$compiledPath = trim(config('help.compiled_path'), '/');
|
||||
$written = [];
|
||||
@@ -87,6 +90,58 @@ class HelpSyncService
|
||||
return $written;
|
||||
}
|
||||
|
||||
private function hydrateRelatedTitles(Collection $articles): Collection
|
||||
{
|
||||
$titleIndex = $articles->mapWithKeys(function (array $article) {
|
||||
$audience = Arr::get($article, 'audience');
|
||||
$locale = Arr::get($article, 'locale');
|
||||
$slug = Arr::get($article, 'slug');
|
||||
|
||||
if (! $audience || ! $locale || ! $slug) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [$this->articleKey((string) $audience, (string) $locale, (string) $slug) => Arr::get($article, 'title')];
|
||||
});
|
||||
|
||||
return $articles->map(function (array $article) use ($titleIndex) {
|
||||
$related = Arr::get($article, 'related', []);
|
||||
|
||||
if (empty($related)) {
|
||||
return $article;
|
||||
}
|
||||
|
||||
$audience = (string) Arr::get($article, 'audience');
|
||||
$locale = (string) Arr::get($article, 'locale');
|
||||
|
||||
$article['related'] = collect($related)
|
||||
->map(function ($item) use ($titleIndex, $audience, $locale) {
|
||||
$slug = is_array($item) ? Arr::get($item, 'slug') : (is_string($item) ? $item : null);
|
||||
|
||||
if (! $slug) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$title = $titleIndex->get($this->articleKey($audience, $locale, $slug));
|
||||
|
||||
return array_filter([
|
||||
'slug' => $slug,
|
||||
'title' => $title,
|
||||
], static fn ($value) => $value !== null && $value !== '');
|
||||
})
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $article;
|
||||
});
|
||||
}
|
||||
|
||||
private function articleKey(string $audience, string $locale, string $slug): string
|
||||
{
|
||||
return sprintf('%s::%s::%s', $audience, $locale, $slug);
|
||||
}
|
||||
|
||||
private function parseFile(SplFileInfo $file): array
|
||||
{
|
||||
$contents = $this->files->get($file->getPathname());
|
||||
|
||||
@@ -233,10 +233,13 @@ class PackageLimitEvaluator
|
||||
config('package-limits.photo_thresholds', [])
|
||||
);
|
||||
|
||||
$guestSummary = $this->buildUsageSummary(
|
||||
$guestSummary = $this->applyGuestGrace(
|
||||
$this->buildUsageSummary(
|
||||
(int) $eventPackage->used_guests,
|
||||
$limits['max_guests'],
|
||||
config('package-limits.guest_thresholds', [])
|
||||
),
|
||||
(int) $eventPackage->used_guests
|
||||
);
|
||||
|
||||
$gallerySummary = $this->buildGallerySummary(
|
||||
@@ -429,4 +432,37 @@ class PackageLimitEvaluator
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{limit: ?int, used: int, remaining: ?int, percentage: ?float, state: string, threshold_reached: ?float, next_threshold: ?float, thresholds: array} $summary
|
||||
* @return array{limit: ?int, used: int, remaining: ?int, percentage: ?float, state: string, threshold_reached: ?float, next_threshold: ?float, thresholds: array}
|
||||
*/
|
||||
private function applyGuestGrace(array $summary, int $used): array
|
||||
{
|
||||
$limit = $summary['limit'] ?? null;
|
||||
if ($limit === null || $limit <= 0) {
|
||||
return $summary;
|
||||
}
|
||||
|
||||
$grace = (int) config('package-limits.guest_grace', 10);
|
||||
$hardLimit = $limit + max(0, $grace);
|
||||
|
||||
if ($used >= $hardLimit) {
|
||||
$summary['state'] = 'limit_reached';
|
||||
$summary['threshold_reached'] = 1.0;
|
||||
$summary['next_threshold'] = null;
|
||||
$summary['remaining'] = 0;
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
if ($used >= $limit) {
|
||||
$summary['state'] = 'warning';
|
||||
$summary['threshold_reached'] = 1.0;
|
||||
$summary['next_threshold'] = null;
|
||||
$summary['remaining'] = 0;
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ class PackageUsageTracker
|
||||
}
|
||||
|
||||
$newUsed = $eventPackage->used_guests;
|
||||
$grace = (int) config('package-limits.guest_grace', 10);
|
||||
$hardLimit = $limit + max(0, $grace);
|
||||
|
||||
$thresholds = collect(config('package-limits.guest_thresholds', []))
|
||||
->filter(fn (float $value) => $value > 0 && $value < 1)
|
||||
@@ -80,8 +82,8 @@ class PackageUsageTracker
|
||||
}
|
||||
}
|
||||
|
||||
if ($newUsed >= $limit && ($previousUsed < $limit)) {
|
||||
$this->dispatcher->dispatch(new EventPackageGuestLimitReached($eventPackage, $limit));
|
||||
if ($newUsed >= $hardLimit && ($previousUsed < $hardLimit)) {
|
||||
$this->dispatcher->dispatch(new EventPackageGuestLimitReached($eventPackage, $hardLimit));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
86
app/Support/SupportApiAuthorizer.php
Normal file
86
app/Support/SupportApiAuthorizer.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SupportApiAuthorizer
|
||||
{
|
||||
public static function authorizeResource(Request $request, string $resource, string $action): ?JsonResponse
|
||||
{
|
||||
$abilities = SupportApiRegistry::abilitiesFor($resource, $action);
|
||||
|
||||
return self::authorizeAbilities($request, $abilities, $action);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $abilities
|
||||
*/
|
||||
public static function authorizeAbilities(Request $request, array $abilities, string $actionLabel = 'resource'): ?JsonResponse
|
||||
{
|
||||
if ($abilities === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$token = $request->user()?->currentAccessToken();
|
||||
|
||||
if (! $token) {
|
||||
return ApiError::response(
|
||||
'unauthenticated',
|
||||
'Unauthenticated',
|
||||
'Missing access token for support request.',
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($abilities as $ability) {
|
||||
if (! $token->can($ability)) {
|
||||
return ApiError::response(
|
||||
'forbidden',
|
||||
'Forbidden',
|
||||
"Missing required ability for support {$actionLabel}.",
|
||||
403,
|
||||
['required' => $abilities]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $abilities
|
||||
*/
|
||||
public static function authorizeAnyAbility(Request $request, array $abilities, string $actionLabel = 'resource'): ?JsonResponse
|
||||
{
|
||||
if ($abilities === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$token = $request->user()?->currentAccessToken();
|
||||
|
||||
if (! $token) {
|
||||
return ApiError::response(
|
||||
'unauthenticated',
|
||||
'Unauthenticated',
|
||||
'Missing access token for support request.',
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($abilities as $ability) {
|
||||
if ($token->can($ability)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return ApiError::response(
|
||||
'forbidden',
|
||||
'Forbidden',
|
||||
"Missing required ability for support {$actionLabel}.",
|
||||
403,
|
||||
['required' => $abilities]
|
||||
);
|
||||
}
|
||||
}
|
||||
148
app/Support/SupportApiRegistry.php
Normal file
148
app/Support/SupportApiRegistry.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
class SupportApiRegistry
|
||||
{
|
||||
/**
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
public static function resources(): array
|
||||
{
|
||||
return config('support-api.resources', []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function get(string $resource): ?array
|
||||
{
|
||||
$resources = self::resources();
|
||||
|
||||
return $resources[$resource] ?? null;
|
||||
}
|
||||
|
||||
public static function validationClass(string $resource, string $action): ?string
|
||||
{
|
||||
$config = self::get($resource);
|
||||
|
||||
if (! $config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$validation = $config['validation'][$action] ?? null;
|
||||
|
||||
return is_string($validation) ? $validation : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function resourceKeys(): array
|
||||
{
|
||||
return array_keys(self::resources());
|
||||
}
|
||||
|
||||
public static function resourcePattern(): string
|
||||
{
|
||||
$keys = self::resourceKeys();
|
||||
|
||||
if ($keys === []) {
|
||||
return '.*';
|
||||
}
|
||||
|
||||
$escaped = array_map(static fn (string $key): string => preg_quote($key, '/'), $keys);
|
||||
|
||||
return implode('|', $escaped);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function abilitiesFor(string $resource, string $action): array
|
||||
{
|
||||
$config = self::get($resource);
|
||||
|
||||
if (! $config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$abilities = $config['abilities'][$action] ?? null;
|
||||
|
||||
if (is_array($abilities) && $abilities !== []) {
|
||||
return $abilities;
|
||||
}
|
||||
|
||||
return match ($action) {
|
||||
'read' => ['support:read'],
|
||||
'write' => ['support:write'],
|
||||
'actions' => ['support:actions'],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
public static function isReadOnly(string $resource): bool
|
||||
{
|
||||
$config = self::get($resource);
|
||||
|
||||
return (bool) ($config['read_only'] ?? false);
|
||||
}
|
||||
|
||||
public static function auditAction(string $resource, string $operation): string
|
||||
{
|
||||
$config = self::get($resource);
|
||||
|
||||
$action = null;
|
||||
|
||||
if ($config && is_array($config['audit'] ?? null)) {
|
||||
$action = $config['audit'][$operation] ?? null;
|
||||
}
|
||||
|
||||
if (is_string($action) && $action !== '') {
|
||||
return $action;
|
||||
}
|
||||
|
||||
return $resource.'.'.$operation;
|
||||
}
|
||||
|
||||
public static function allowsMutation(string $resource, string $action): bool
|
||||
{
|
||||
if (self::isReadOnly($resource)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$config = self::get($resource);
|
||||
|
||||
$mutations = $config['mutations'] ?? null;
|
||||
|
||||
if (! is_array($mutations)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (bool) ($mutations[$action] ?? false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function searchFields(string $resource): array
|
||||
{
|
||||
$config = self::get($resource);
|
||||
|
||||
$fields = $config['search'] ?? [];
|
||||
|
||||
return is_array($fields) ? $fields : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function withRelations(string $resource): array
|
||||
{
|
||||
$config = self::get($resource);
|
||||
|
||||
$relations = $config['with'] ?? [];
|
||||
|
||||
return is_array($relations) ? $relations : [];
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\CreditCheckMiddleware;
|
||||
use App\Http\Middleware\EnsureSupportToken;
|
||||
use App\Http\Middleware\EnsureTenantAdminToken;
|
||||
use App\Http\Middleware\EnsureTenantCollaboratorToken;
|
||||
use App\Http\Middleware\HandleAppearance;
|
||||
@@ -104,6 +105,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
'credit.check' => CreditCheckMiddleware::class,
|
||||
'tenant.admin' => EnsureTenantAdminToken::class,
|
||||
'tenant.collaborator' => EnsureTenantCollaboratorToken::class,
|
||||
'support.token' => EnsureSupportToken::class,
|
||||
]);
|
||||
|
||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"minishlink/web-push": "*",
|
||||
"sentry/sentry-laravel": "*",
|
||||
"simplesoftwareio/simple-qrcode": "^4.2",
|
||||
"spatie/laravel-honeypot": "*",
|
||||
"spatie/laravel-translatable": "^6.11",
|
||||
"staudenmeir/belongs-to-through": "^2.17",
|
||||
"stripe/stripe-php": "*",
|
||||
|
||||
78
composer.lock
generated
78
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "5e1d60e650853d6113b01e1adaf49d65",
|
||||
"content-hash": "a4956012b0e374c8f74b61a892e6b984",
|
||||
"packages": [
|
||||
{
|
||||
"name": "anourvalar/eloquent-serialize",
|
||||
@@ -6804,6 +6804,82 @@
|
||||
],
|
||||
"time": "2024-05-17T09:06:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/laravel-honeypot",
|
||||
"version": "4.6.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/laravel-honeypot.git",
|
||||
"reference": "62ec9dbecd2a17a4e2af62b09675f89813295cac"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/laravel-honeypot/zipball/62ec9dbecd2a17a4e2af62b09675f89813295cac",
|
||||
"reference": "62ec9dbecd2a17a4e2af62b09675f89813295cac",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/contracts": "^11.0|^12.0",
|
||||
"illuminate/encryption": "^11.0|^12.0",
|
||||
"illuminate/http": "^11.0|^12.0",
|
||||
"illuminate/support": "^11.0|^12.0",
|
||||
"illuminate/validation": "^11.0|^12.0",
|
||||
"nesbot/carbon": "^2.0|^3.0",
|
||||
"php": "^8.2",
|
||||
"spatie/laravel-package-tools": "^1.9",
|
||||
"symfony/http-foundation": "^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"livewire/livewire": "^3.0",
|
||||
"orchestra/testbench": "^9.0|^10.0",
|
||||
"pestphp/pest": "^2.0|^3.0|^4.0",
|
||||
"pestphp/pest-plugin-livewire": "^1.0|^2.1|^3.0|^4.0",
|
||||
"spatie/pest-plugin-snapshots": "^1.1|^2.1",
|
||||
"spatie/phpunit-snapshot-assertions": "^4.2|^5.1",
|
||||
"spatie/test-time": "^1.2.1"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Spatie\\Honeypot\\HoneypotServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\Honeypot\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Freek Van der Herten",
|
||||
"email": "freek@spatie.be",
|
||||
"homepage": "https://spatie.be",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Preventing spam submitted through forms",
|
||||
"homepage": "https://github.com/spatie/laravel-honeypot",
|
||||
"keywords": [
|
||||
"laravel-honeypot",
|
||||
"spatie"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/spatie/laravel-honeypot/tree/4.6.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://spatie.be/open-source/support-us",
|
||||
"type": "custom"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-28T09:57:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/laravel-package-tools",
|
||||
"version": "1.92.7",
|
||||
|
||||
@@ -8,5 +8,6 @@ return [
|
||||
],
|
||||
'web_url' => env('DOKPLOY_WEB_URL'),
|
||||
'applications' => json_decode(env('DOKPLOY_APPLICATION_IDS', '{}'), true) ?? [],
|
||||
'projects' => json_decode(env('DOKPLOY_PROJECT_IDS', '{}'), true) ?? [],
|
||||
'composes' => json_decode(env('DOKPLOY_COMPOSE_IDS', '{}'), true) ?? [],
|
||||
];
|
||||
|
||||
@@ -30,21 +30,21 @@ return [
|
||||
],
|
||||
[
|
||||
'key' => 'gift-standard',
|
||||
'label' => 'Geschenk Standard',
|
||||
'label' => 'Geschenk Classic',
|
||||
'amount' => 59.00,
|
||||
'currency' => 'EUR',
|
||||
'paddle_price_id' => env('PADDLE_GIFT_PRICE_STANDARD', 'pri_01kbwccfvzrf4z2f1r62vns7gh'),
|
||||
],
|
||||
[
|
||||
'key' => 'gift-standard-usd',
|
||||
'label' => 'Gift Standard (USD)',
|
||||
'label' => 'Gift Classic (USD)',
|
||||
'amount' => 65.00,
|
||||
'currency' => 'USD',
|
||||
'paddle_price_id' => env('PADDLE_GIFT_PRICE_STANDARD_USD'),
|
||||
],
|
||||
[
|
||||
'key' => 'gift-standard-gbp',
|
||||
'label' => 'Gift Standard (GBP)',
|
||||
'label' => 'Gift Classic (GBP)',
|
||||
'amount' => 55.00,
|
||||
'currency' => 'GBP',
|
||||
'paddle_price_id' => env('PADDLE_GIFT_PRICE_STANDARD_GBP'),
|
||||
@@ -70,27 +70,6 @@ return [
|
||||
'currency' => 'GBP',
|
||||
'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM_GBP'),
|
||||
],
|
||||
[
|
||||
'key' => 'gift-premium-plus',
|
||||
'label' => 'Geschenk Premium Plus',
|
||||
'amount' => 149.00,
|
||||
'currency' => 'EUR',
|
||||
'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM_PLUS', 'pri_01kbwccgnjzwrjy5xg1yp981p6'),
|
||||
],
|
||||
[
|
||||
'key' => 'gift-premium-plus-usd',
|
||||
'label' => 'Gift Premium Plus (USD)',
|
||||
'amount' => 159.00,
|
||||
'currency' => 'USD',
|
||||
'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM_PLUS_USD'),
|
||||
],
|
||||
[
|
||||
'key' => 'gift-premium-plus-gbp',
|
||||
'label' => 'Gift Premium Plus (GBP)',
|
||||
'amount' => 139.00,
|
||||
'currency' => 'GBP',
|
||||
'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM_PLUS_GBP'),
|
||||
],
|
||||
],
|
||||
|
||||
// Package types a voucher coupon should apply to.
|
||||
|
||||
@@ -9,6 +9,7 @@ return [
|
||||
0.8,
|
||||
0.95,
|
||||
],
|
||||
'guest_grace' => 10,
|
||||
'gallery_warning_days' => [
|
||||
7,
|
||||
1,
|
||||
|
||||
@@ -57,6 +57,11 @@ return [
|
||||
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
|
||||
'redirect' => env('GOOGLE_REDIRECT_URI', rtrim(env('APP_URL', ''), '/').'/checkout/auth/google/callback'),
|
||||
],
|
||||
'facebook' => [
|
||||
'client_id' => env('FACEBOOK_CLIENT_ID'),
|
||||
'client_secret' => env('FACEBOOK_CLIENT_SECRET'),
|
||||
'redirect' => env('FACEBOOK_REDIRECT_URI', rtrim(env('APP_URL', ''), '/').'/checkout/auth/facebook/callback'),
|
||||
],
|
||||
|
||||
'matomo' => [
|
||||
'enabled' => env('MATOMO_ENABLED', false),
|
||||
|
||||
@@ -12,6 +12,15 @@ return [
|
||||
'critical' => (int) env('STORAGE_CAPACITY_CRITICAL', 90),
|
||||
],
|
||||
|
||||
'checksum_validation' => [
|
||||
'enabled' => (bool) env('STORAGE_CHECKSUM_VALIDATION', true),
|
||||
'alert_window_minutes' => (int) env('STORAGE_CHECKSUM_ALERT_WINDOW_MINUTES', 60),
|
||||
'thresholds' => [
|
||||
'warning' => (int) env('STORAGE_CHECKSUM_WARNING', 1),
|
||||
'critical' => (int) env('STORAGE_CHECKSUM_CRITICAL', 5),
|
||||
],
|
||||
],
|
||||
|
||||
'monitor' => [
|
||||
'lock_seconds' => (int) env('STORAGE_MONITOR_LOCK_SECONDS', 300),
|
||||
'cache_minutes' => (int) env('STORAGE_MONITOR_CACHE_MINUTES', 15),
|
||||
|
||||
418
config/support-api.php
Normal file
418
config/support-api.php
Normal file
@@ -0,0 +1,418 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Requests\Support\Resources\SupportBlogPostResourceRequest;
|
||||
use App\Http\Requests\Support\Resources\SupportDataExportResourceRequest;
|
||||
use App\Http\Requests\Support\Resources\SupportEmotionResourceRequest;
|
||||
use App\Http\Requests\Support\Resources\SupportEventResourceRequest;
|
||||
use App\Http\Requests\Support\Resources\SupportPhotoboothSettingResourceRequest;
|
||||
use App\Http\Requests\Support\Resources\SupportPhotoResourceRequest;
|
||||
use App\Http\Requests\Support\Resources\SupportTaskResourceRequest;
|
||||
use App\Http\Requests\Support\Resources\SupportTenantFeedbackResourceRequest;
|
||||
use App\Http\Requests\Support\Resources\SupportTenantResourceRequest;
|
||||
use App\Http\Requests\Support\Resources\SupportUserResourceRequest;
|
||||
use App\Models\BlogCategory;
|
||||
use App\Models\BlogPost;
|
||||
use App\Models\Coupon;
|
||||
use App\Models\DataExport;
|
||||
use App\Models\Emotion;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPurchase;
|
||||
use App\Models\EventType;
|
||||
use App\Models\GiftVoucher;
|
||||
use App\Models\InfrastructureActionLog;
|
||||
use App\Models\LegalPage;
|
||||
use App\Models\MediaStorageTarget;
|
||||
use App\Models\Package;
|
||||
use App\Models\PackageAddon;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\Photo;
|
||||
use App\Models\PhotoboothSetting;
|
||||
use App\Models\PurchaseHistory;
|
||||
use App\Models\RetentionOverride;
|
||||
use App\Models\SuperAdminActionLog;
|
||||
use App\Models\Task;
|
||||
use App\Models\TaskCollection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantAnnouncement;
|
||||
use App\Models\TenantFeedback;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Models\User;
|
||||
|
||||
return [
|
||||
'token' => [
|
||||
'name' => 'support-api',
|
||||
'default_abilities' => [
|
||||
'support-admin',
|
||||
'support:read',
|
||||
'support:write',
|
||||
'support:actions',
|
||||
'support:billing',
|
||||
'support:ops',
|
||||
'support:content',
|
||||
'support:settings',
|
||||
'support:infrastructure',
|
||||
],
|
||||
],
|
||||
'pagination' => [
|
||||
'default_per_page' => 50,
|
||||
'max_per_page' => 200,
|
||||
],
|
||||
'resources' => [
|
||||
'tenants' => [
|
||||
'model' => Tenant::class,
|
||||
'search' => ['name', 'slug', 'contact_email', 'paddle_customer_id'],
|
||||
'with' => ['user', 'activeResellerPackage'],
|
||||
'abilities' => [
|
||||
'read' => ['support:read'],
|
||||
'write' => ['support:write'],
|
||||
'actions' => ['support:actions'],
|
||||
],
|
||||
'validation' => [
|
||||
'update' => SupportTenantResourceRequest::class,
|
||||
],
|
||||
'mutations' => [
|
||||
'create' => false,
|
||||
'update' => true,
|
||||
'delete' => false,
|
||||
],
|
||||
],
|
||||
'users' => [
|
||||
'model' => User::class,
|
||||
'search' => ['email', 'username', 'name', 'first_name', 'last_name'],
|
||||
'abilities' => [
|
||||
'read' => ['support:read'],
|
||||
'write' => ['support:write'],
|
||||
],
|
||||
'validation' => [
|
||||
'update' => SupportUserResourceRequest::class,
|
||||
],
|
||||
'mutations' => [
|
||||
'create' => false,
|
||||
'update' => true,
|
||||
'delete' => false,
|
||||
],
|
||||
],
|
||||
'events' => [
|
||||
'model' => Event::class,
|
||||
'search' => ['name', 'slug'],
|
||||
'abilities' => [
|
||||
'read' => ['support:read'],
|
||||
'write' => ['support:write'],
|
||||
],
|
||||
'validation' => [
|
||||
'update' => SupportEventResourceRequest::class,
|
||||
],
|
||||
'mutations' => [
|
||||
'create' => false,
|
||||
'update' => true,
|
||||
'delete' => true,
|
||||
],
|
||||
],
|
||||
'event-types' => [
|
||||
'model' => EventType::class,
|
||||
'search' => ['name', 'slug'],
|
||||
'read_only' => true,
|
||||
'abilities' => [
|
||||
'read' => ['support:read'],
|
||||
],
|
||||
'mutations' => [
|
||||
'create' => false,
|
||||
'update' => false,
|
||||
'delete' => false,
|
||||
],
|
||||
],
|
||||
'photos' => [
|
||||
'model' => Photo::class,
|
||||
'search' => ['id'],
|
||||
'abilities' => [
|
||||
'read' => ['support:read'],
|
||||
'write' => ['support:write'],
|
||||
],
|
||||
'validation' => [
|
||||
'update' => SupportPhotoResourceRequest::class,
|
||||
],
|
||||
'mutations' => [
|
||||
'create' => false,
|
||||
'update' => true,
|
||||
'delete' => true,
|
||||
],
|
||||
],
|
||||
'event-purchases' => [
|
||||
'model' => EventPurchase::class,
|
||||
'search' => ['id'],
|
||||
'read_only' => true,
|
||||
'abilities' => [
|
||||
'read' => ['support:billing'],
|
||||
],
|
||||
],
|
||||
'purchases' => [
|
||||
'model' => PackagePurchase::class,
|
||||
'search' => ['provider_id'],
|
||||
'read_only' => true,
|
||||
'abilities' => [
|
||||
'read' => ['support:billing'],
|
||||
],
|
||||
],
|
||||
'purchase-histories' => [
|
||||
'model' => PurchaseHistory::class,
|
||||
'search' => ['provider_id'],
|
||||
'read_only' => true,
|
||||
'abilities' => [
|
||||
'read' => ['support:billing'],
|
||||
],
|
||||
],
|
||||
'packages' => [
|
||||
'model' => Package::class,
|
||||
'search' => ['name', 'slug'],
|
||||
'read_only' => true,
|
||||
'abilities' => [
|
||||
'read' => ['support:billing'],
|
||||
],
|
||||
'mutations' => [
|
||||
'create' => false,
|
||||
'update' => false,
|
||||
'delete' => false,
|
||||
],
|
||||
],
|
||||
'package-addons' => [
|
||||
'model' => PackageAddon::class,
|
||||
'search' => ['name', 'slug'],
|
||||
'read_only' => true,
|
||||
'abilities' => [
|
||||
'read' => ['support:billing'],
|
||||
],
|
||||
'mutations' => [
|
||||
'create' => false,
|
||||
'update' => false,
|
||||
'delete' => false,
|
||||
],
|
||||
],
|
||||
'tenant-packages' => [
|
||||
'model' => TenantPackage::class,
|
||||
'search' => ['id'],
|
||||
'read_only' => true,
|
||||
'abilities' => [
|
||||
'read' => ['support:billing'],
|
||||
],
|
||||
],
|
||||
'coupons' => [
|
||||
'model' => Coupon::class,
|
||||
'search' => ['code', 'name'],
|
||||
'read_only' => true,
|
||||
'abilities' => [
|
||||
'read' => ['support:billing'],
|
||||
],
|
||||
'mutations' => [
|
||||
'create' => false,
|
||||
'update' => false,
|
||||
'delete' => false,
|
||||
],
|
||||
],
|
||||
'gift-vouchers' => [
|
||||
'model' => GiftVoucher::class,
|
||||
'search' => ['code', 'email'],
|
||||
'read_only' => true,
|
||||
'abilities' => [
|
||||
'read' => ['support:billing'],
|
||||
],
|
||||
'mutations' => [
|
||||
'create' => false,
|
||||
'update' => false,
|
||||
'delete' => false,
|
||||
],
|
||||
],
|
||||
'tenant-feedback' => [
|
||||
'model' => TenantFeedback::class,
|
||||
'search' => ['email', 'message'],
|
||||
'abilities' => [
|
||||
'read' => ['support:read'],
|
||||
'write' => ['support:write'],
|
||||
],
|
||||
'validation' => [
|
||||
'update' => SupportTenantFeedbackResourceRequest::class,
|
||||
],
|
||||
'mutations' => [
|
||||
'create' => false,
|
||||
'update' => true,
|
||||
'delete' => false,
|
||||
],
|
||||
],
|
||||
'tenant-announcements' => [
|
||||
'model' => TenantAnnouncement::class,
|
||||
'search' => ['title', 'body'],
|
||||
'read_only' => true,
|
||||
'abilities' => [
|
||||
'read' => ['support:read'],
|
||||
],
|
||||
'mutations' => [
|
||||
'create' => false,
|
||||
'update' => false,
|
||||
'delete' => false,
|
||||
],
|
||||
],
|
||||
'media-storage-targets' => [
|
||||
'model' => MediaStorageTarget::class,
|
||||
'search' => ['name', 'driver'],
|
||||
'read_only' => true,
|
||||
'abilities' => [
|
||||
'read' => ['support:ops'],
|
||||
],
|
||||
'mutations' => [
|
||||
'create' => false,
|
||||
'update' => false,
|
||||
'delete' => false,
|
||||
],
|
||||
],
|
||||
'retention-overrides' => [
|
||||
'model' => RetentionOverride::class,
|
||||
'search' => ['id'],
|
||||
'read_only' => true,
|
||||
'abilities' => [
|
||||
'read' => ['support:ops'],
|
||||
],
|
||||
'mutations' => [
|
||||
'create' => false,
|
||||
'update' => false,
|
||||
'delete' => false,
|
||||
],
|
||||
],
|
||||
'data-exports' => [
|
||||
'model' => DataExport::class,
|
||||
'search' => ['id'],
|
||||
'abilities' => [
|
||||
'read' => ['support:ops'],
|
||||
'write' => ['support:ops'],
|
||||
],
|
||||
'validation' => [
|
||||
'create' => SupportDataExportResourceRequest::class,
|
||||
],
|
||||
'mutations' => [
|
||||
'create' => true,
|
||||
'update' => false,
|
||||
'delete' => false,
|
||||
],
|
||||
],
|
||||
'photobooth-settings' => [
|
||||
'model' => PhotoboothSetting::class,
|
||||
'search' => ['label'],
|
||||
'abilities' => [
|
||||
'read' => ['support:ops'],
|
||||
'write' => ['support:ops'],
|
||||
],
|
||||
'validation' => [
|
||||
'update' => SupportPhotoboothSettingResourceRequest::class,
|
||||
],
|
||||
'mutations' => [
|
||||
'create' => false,
|
||||
'update' => true,
|
||||
'delete' => false,
|
||||
],
|
||||
],
|
||||
'legal-pages' => [
|
||||
'model' => LegalPage::class,
|
||||
'search' => ['slug', 'title'],
|
||||
'read_only' => true,
|
||||
'abilities' => [
|
||||
'read' => ['support:content'],
|
||||
],
|
||||
'mutations' => [
|
||||
'create' => false,
|
||||
'update' => false,
|
||||
'delete' => false,
|
||||
],
|
||||
],
|
||||
'blog-categories' => [
|
||||
'model' => BlogCategory::class,
|
||||
'search' => ['name', 'slug'],
|
||||
'read_only' => true,
|
||||
'abilities' => [
|
||||
'read' => ['support:content'],
|
||||
],
|
||||
'mutations' => [
|
||||
'create' => false,
|
||||
'update' => false,
|
||||
'delete' => false,
|
||||
],
|
||||
],
|
||||
'blog-posts' => [
|
||||
'model' => BlogPost::class,
|
||||
'search' => ['title', 'slug'],
|
||||
'abilities' => [
|
||||
'read' => ['support:content'],
|
||||
'write' => ['support:content'],
|
||||
],
|
||||
'validation' => [
|
||||
'create' => SupportBlogPostResourceRequest::class,
|
||||
'update' => SupportBlogPostResourceRequest::class,
|
||||
],
|
||||
'mutations' => [
|
||||
'create' => true,
|
||||
'update' => true,
|
||||
'delete' => true,
|
||||
],
|
||||
],
|
||||
'emotions' => [
|
||||
'model' => Emotion::class,
|
||||
'search' => ['name', 'slug'],
|
||||
'abilities' => [
|
||||
'read' => ['support:content'],
|
||||
'write' => ['support:content'],
|
||||
],
|
||||
'validation' => [
|
||||
'create' => SupportEmotionResourceRequest::class,
|
||||
'update' => SupportEmotionResourceRequest::class,
|
||||
],
|
||||
'mutations' => [
|
||||
'create' => true,
|
||||
'update' => true,
|
||||
'delete' => true,
|
||||
],
|
||||
],
|
||||
'tasks' => [
|
||||
'model' => Task::class,
|
||||
'search' => ['title'],
|
||||
'abilities' => [
|
||||
'read' => ['support:content'],
|
||||
'write' => ['support:content'],
|
||||
],
|
||||
'validation' => [
|
||||
'create' => SupportTaskResourceRequest::class,
|
||||
'update' => SupportTaskResourceRequest::class,
|
||||
],
|
||||
'mutations' => [
|
||||
'create' => true,
|
||||
'update' => true,
|
||||
'delete' => true,
|
||||
],
|
||||
],
|
||||
'task-collections' => [
|
||||
'model' => TaskCollection::class,
|
||||
'search' => ['name'],
|
||||
'read_only' => true,
|
||||
'abilities' => [
|
||||
'read' => ['support:read'],
|
||||
],
|
||||
'mutations' => [
|
||||
'create' => false,
|
||||
'update' => false,
|
||||
'delete' => false,
|
||||
],
|
||||
],
|
||||
'super-admin-action-logs' => [
|
||||
'model' => SuperAdminActionLog::class,
|
||||
'search' => ['action', 'target_type'],
|
||||
'read_only' => true,
|
||||
'abilities' => [
|
||||
'read' => ['support:infrastructure'],
|
||||
],
|
||||
],
|
||||
'infrastructure-action-logs' => [
|
||||
'model' => InfrastructureActionLog::class,
|
||||
'search' => ['action', 'target_type'],
|
||||
'read_only' => true,
|
||||
'abilities' => [
|
||||
'read' => ['support:infrastructure'],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -41,7 +41,7 @@ class BlogPostErgaenzungSeeder extends Seeder
|
||||
$articles = [
|
||||
[
|
||||
'slug' => 'nachhaltige-events-und-hochzeiten',
|
||||
'published_at' => '2026-04-16 10:00:00',
|
||||
'published_at' => '2026-05-07 10:00:00',
|
||||
'de' => [
|
||||
'title' => 'Nachhaltige Events & Hochzeiten – So feiert ihr bewusst, ohne auf Emotionen zu verzichten',
|
||||
'excerpt' => 'Green Wedding, Zero Waste Event, klimabewusste Firmenfeier – nachhaltige Events sind mehr als ein Trend. In diesem Leitfaden erfährst du, wie du Deko, Drucksachen, Catering und natürlich die Fotografie so planst, dass sie zur Umwelt und zu euch passen – inklusive Ideen, wie die Fotospiel App digitale Prozesse vereinfacht.',
|
||||
@@ -300,7 +300,7 @@ Sustainability and emotional events are not opposites – they reinforce each ot
|
||||
// 2. Ideale Timeline für Hochzeitsfotos
|
||||
[
|
||||
'slug' => 'ideale-hochzeitsfoto-timeline',
|
||||
'published_at' => '2026-04-24 10:00:00',
|
||||
'published_at' => '2026-05-15 10:00:00',
|
||||
'de' => [
|
||||
'title' => 'Die ideale Timeline für Hochzeitsfotos – So verpasst ihr keinen Moment',
|
||||
'excerpt' => 'First Look, Trauung, Gruppenbilder, Paarshooting, Party: Eine gute Foto-Timeline sorgt dafür, dass ihr alle wichtigen Momente entspannt erlebt – und trotzdem ein komplettes Album bekommt. In diesem Artikel zeige ich dir, wie du euren Hochzeitstag fotografisch planst, ohne dass er sich nach Drehplan anfühlt.',
|
||||
@@ -497,7 +497,7 @@ A clear photo timeline doesn\'t make your day rigid – it makes it easier. You
|
||||
// 3. Fotospiele für Kinder
|
||||
[
|
||||
'slug' => 'fotospiele-fuer-kinder-auf-hochzeiten-und-events',
|
||||
'published_at' => '2026-05-05 10:00:00',
|
||||
'published_at' => '2026-05-26 10:00:00',
|
||||
'de' => [
|
||||
'title' => 'Fotospiele für Kinder auf Hochzeiten & Events – So werden die Kleinsten zu großen Geschichtenerzählern',
|
||||
'excerpt' => 'Kinder auf Events sind Wunderwaffen: Sie nehmen Druck aus der Situation, sorgen für ehrliches Lachen – und sehen die Welt aus einer ganz anderen Perspektive. Mit kindgerechten Fotospielen beschäftigst du sie sinnvoll und bekommst gleichzeitig einzigartige Bilder.',
|
||||
@@ -639,7 +639,7 @@ Photo games turn children into **active storytellers**. Their pictures may be cr
|
||||
// 4. Storytelling mit Bildern / Album
|
||||
[
|
||||
'slug' => 'storytelling-mit-eventfotos',
|
||||
'published_at' => '2026-05-14 10:00:00',
|
||||
'published_at' => '2026-06-04 10:00:00',
|
||||
'de' => [
|
||||
'title' => 'Storytelling mit Eventfotos – So wird aus Bildern eine Geschichte',
|
||||
'excerpt' => 'Hunderte Fotos sind schnell gemacht – aber erst eine gute Auswahl und Reihenfolge macht daraus eine Geschichte, die man gerne anschaut. In diesem Artikel zeige ich dir, wie du aus Profi- und Gastfotos ein stimmiges Album, eine Slideshow oder ein Recap-Video baust.',
|
||||
@@ -828,7 +828,7 @@ Photos are not just proof that something happened. When curated thoughtfully, th
|
||||
// 5. Hybride Events
|
||||
[
|
||||
'slug' => 'hybride-events-und-remote-gaeste',
|
||||
'published_at' => '2026-05-24 10:00:00',
|
||||
'published_at' => '2026-06-14 10:00:00',
|
||||
'de' => [
|
||||
'title' => 'Hybride Events & entfernte Gäste – So werden alle Teil der Foto-Geschichte',
|
||||
'excerpt' => 'Nicht alle können bei einer Hochzeit oder einem Firmenevent live dabei sein – trotzdem sollen sie Teil der Erinnerungen werden. In diesem Artikel erfährst du, wie du vor Ort und remote Gäste in einer gemeinsamen Fotostory verbindest.',
|
||||
@@ -953,7 +953,7 @@ This way, people feel: **We were part of the same story**, even if we weren\'t i
|
||||
// 6. Checkliste Fotowand
|
||||
[
|
||||
'slug' => 'checkliste-fotowand-und-selfie-station',
|
||||
'published_at' => '2026-06-02 10:00:00',
|
||||
'published_at' => '2026-06-23 10:00:00',
|
||||
'de' => [
|
||||
'title' => 'Checkliste Fotowand & Selfie-Station – So entstehen eure meistgenutzten Eventmotive',
|
||||
'excerpt' => 'Eine gute Fotowand ist nicht nur „nice to have“, sondern Magnet für witzige Gruppenbilder und Selfies. Mit dieser Checkliste richtest du eine Fotowand ein, die auf Fotos großartig aussieht – und perfekt zur Fotospiel App passt.',
|
||||
@@ -1068,7 +1068,7 @@ With thoughtful placement, simple lighting, and integration into the Photo Game
|
||||
// 7. Kamerascheue Gäste
|
||||
[
|
||||
'slug' => 'kamerascheue-gaeste-respektvoll-fotografieren',
|
||||
'published_at' => '2026-06-11 10:00:00',
|
||||
'published_at' => '2026-07-02 10:00:00',
|
||||
'de' => [
|
||||
'title' => 'Kamerascheue Gäste respektvoll fotografieren – So bleibt ihr nah dran, ohne Grenzen zu überschreiten',
|
||||
'excerpt' => 'Nicht alle Menschen lieben Kameras – und trotzdem sollen sie sich auf eurem Event wohl fühlen. Hier erfährst du, wie du kamerascheue Gäste respektierst und trotzdem eine vollständige Bildgeschichte erzielst.',
|
||||
@@ -1187,7 +1187,7 @@ When guests feel seen and respected, they are much more likely to **willingly pa
|
||||
// 8. After-Event Marketing für Firmen
|
||||
[
|
||||
'slug' => 'eventfotos-im-marketing-nutzen',
|
||||
'published_at' => '2026-06-21 10:00:00',
|
||||
'published_at' => '2026-07-12 10:00:00',
|
||||
'de' => [
|
||||
'title' => 'Eventfotos im Marketing nutzen – Ohne peinliche Bilder und rechtliche Stolperfallen',
|
||||
'excerpt' => 'Firmenevents liefern wertvollen Content für Employer Branding, Social Media und Recruiting. Hier erfährst du, wie du Eventfotos sinnvoll und DSGVO-bewusst im Marketing einsetzt.',
|
||||
@@ -1326,7 +1326,7 @@ Event photos can be a strong marketing tool when you balance visibility with res
|
||||
// 9. Mikro-Momente statt gestellter Posen
|
||||
[
|
||||
'slug' => 'mikro-momente-statt-gestellter-posen',
|
||||
'published_at' => '2026-07-01 10:00:00',
|
||||
'published_at' => '2026-07-22 10:00:00',
|
||||
'de' => [
|
||||
'title' => 'Mikro-Momente statt gestellter Posen – Wie ihr echte Emotionen einfängt',
|
||||
'excerpt' => 'Die stärksten Eventfotos sind selten die perfekt inszenierten Motive – sondern die kleinen, echten Momente dazwischen. In diesem Artikel geht es darum, den Blick für Mikro-Momente zu schärfen und Gäste aktiv daran zu beteiligen.',
|
||||
@@ -1447,7 +1447,7 @@ Once you start valuing micro-moments, your photography changes. The programme is
|
||||
// 10. Technisches Setup
|
||||
[
|
||||
'slug' => 'technisches-setup-fuer-stressfreie-eventfotografie',
|
||||
'published_at' => '2026-07-10 10:00:00',
|
||||
'published_at' => '2026-07-31 10:00:00',
|
||||
'de' => [
|
||||
'title' => 'Technisches Setup für stressfreie Eventfotografie – WLAN, Strom & Uploads im Griff',
|
||||
'excerpt' => 'Die beste Fotoidee bringt wenig, wenn WLAN ausfällt, Akkus leer sind oder Uploads hängen. In diesem Artikel geht es um das technische Mindest-Setup, damit Fotospiel App, Uploads und Fotos insgesamt zuverlässig funktionieren.',
|
||||
|
||||
@@ -48,7 +48,7 @@ class BlogPostSeeder extends Seeder
|
||||
$articles = [
|
||||
[
|
||||
'slug' => '10-kreative-fotoaufgaben-fuer-eure-hochzeit',
|
||||
'published_at' => '2026-01-11 10:00:00',
|
||||
'published_at' => '2026-02-01 10:00:00',
|
||||
'de' => [
|
||||
'title' => '10 kreative Fotoaufgaben für eure Hochzeit – Ideen, die Spaß machen und Emotionen wecken',
|
||||
'excerpt' => 'Jede Hochzeit erzählt ihre eigene Geschichte – aber oft fehlen genau die Fotos, die das echte Gefühl des Tages zeigen: das spontane Lachen, die liebevollen Blicke, die kleinen Missgeschicke, die später zu euren Lieblingsmomenten werden. Die Lösung: **Fotoaufgaben für Gäste!**',
|
||||
@@ -288,7 +288,7 @@ Try the Photo Game App for your wedding – scan the QR code, start the challeng
|
||||
],
|
||||
[
|
||||
'slug' => 'datenschutz-bei-eventfotos',
|
||||
'published_at' => '2025-12-29 09:00:00',
|
||||
'published_at' => '2026-01-19 09:00:00',
|
||||
'de' => [
|
||||
'title' => 'Datenschutz bei Eventfotos – Was du wissen musst (verständlich erklärt)',
|
||||
'excerpt' => 'Ein rauschendes Fest, fröhliche Menschen, unzählige Kameras – und schon schwebt die Frage im Raum: **Darf man das eigentlich alles fotografieren und teilen?** Mit unserer Fotospiel App sicher und DSGVO-konform!',
|
||||
@@ -506,7 +506,7 @@ Scan the event QR code, inform your guests – and collect memories with a clear
|
||||
],
|
||||
[
|
||||
'slug' => 'firmenevent-unvergesslich-fotos-spiele-teamgeist',
|
||||
'published_at' => '2026-01-05 11:00:00',
|
||||
'published_at' => '2026-01-26 11:00:00',
|
||||
'de' => [
|
||||
'title' => 'So macht ihr euer Firmenevent unvergesslich – Fotos, Spiele & Teamgeist',
|
||||
'excerpt' => 'Ein gelungenes Firmenevent ist mehr als ein Buffet und ein paar Reden. Es ist die Gelegenheit, das Wir-Gefühl zu stärken, neue Energie zu tanken – und Erinnerungen zu schaffen, die lange nachwirken. Doch wie entsteht aus einem „netten Abend" ein echtes Gemeinschaftserlebnis?',
|
||||
@@ -735,7 +735,7 @@ Plan your next corporate event with clear emotional moments, interactive element
|
||||
],
|
||||
[
|
||||
'slug' => 'hochzeitsbilder-qr-code',
|
||||
'published_at' => '2025-12-27 10:30:00',
|
||||
'published_at' => '2026-01-17 10:30:00',
|
||||
'de' => [
|
||||
'title' => 'Hochzeitsbilder & QR‑Code – Die moderne Art, Erinnerungen zu sammeln und zu teilen',
|
||||
'excerpt' => 'Du kennst das Spiel: Nach der Hochzeit hat jede*r tolle Bilder auf dem Handy – doch Wochen später fehlen sie im gemeinsamen Album. Ein **QR‑Code** beendet dieses Chaos und macht das Teilen von Hochzeitsfotos so einfach wie einen kurzen Scan. In diesem Leitfaden zeige ich dir, wie du QR‑Codes **smart, datenschutzfreundlich und mit Praxis‑Tipps** einsetzt – und wie unsere **Fotospiel App** daraus ein echtes Gemeinschaftserlebnis macht.',
|
||||
@@ -867,7 +867,7 @@ That way, a small black-and-white square becomes the key to your shared wedding
|
||||
],
|
||||
[
|
||||
'slug' => 'hochzeitsfotografie-mit-kleinem-budget',
|
||||
'published_at' => '2026-01-14 09:30:00',
|
||||
'published_at' => '2026-02-04 09:30:00',
|
||||
'de' => [
|
||||
'title' => 'Hochzeitsfotografie mit kleinem Budget – So bekommst du traumhafte Erinnerungen ohne Profi-Fotograf',
|
||||
'excerpt' => 'Heiraten ist wunderschön – aber teuer. Wenn du mitten in der Planung steckst, weißt du: Kleid, Location, Musik, Essen … alles summiert sich. Und dann kommt da noch der Fotograf mit seinem stolzen Preis. Doch gute Nachrichten: **Wunderschöne Hochzeitsfotos müssen kein Luxus sein.**',
|
||||
@@ -975,7 +975,7 @@ You don\'t need an expensive all‑inclusive photo package to get moving, meanin
|
||||
],
|
||||
[
|
||||
'slug' => 'qr-codes-auf-events-kreativ-nutzen',
|
||||
'published_at' => '2025-12-31 12:00:00',
|
||||
'published_at' => '2026-01-21 12:00:00',
|
||||
'de' => [
|
||||
'title' => 'So nutzt ihr QR-Codes auf Events kreativ – Von Fotos bis Networking',
|
||||
'excerpt' => 'QR-Codes sind längst mehr als kleine schwarz-weiße Quadrate. Sie sind die Brücke zwischen der realen und der digitalen Welt – und auf Events die **einfachste Möglichkeit, Interaktion zu schaffen**. Ob Hochzeit, Firmenevent oder Festival: Mit einem Scan können Gäste Fotos hochladen, Feedback geben, Kontakte austauschen oder Informationen abrufen. In diesem Artikel zeige ich dir, wie du QR-Codes kreativ nutzt – mit vielen Praxis-Tipps und konkreten Ideen für den Einsatz mit **unserer Fotospiel App**.',
|
||||
@@ -1109,7 +1109,7 @@ QR codes are simple to set up but incredibly powerful when used intentionally. T
|
||||
],
|
||||
[
|
||||
'slug' => 'top-alternativen-zum-hochzeitsfotografen',
|
||||
'published_at' => '2026-01-08 10:00:00',
|
||||
'published_at' => '2026-01-29 10:00:00',
|
||||
'de' => [
|
||||
'title' => 'Top-Alternativen zum Hochzeitsfotografen – So bekommst du echte Erinnerungen ohne Profi-Shooting',
|
||||
'excerpt' => 'Ein professioneller Fotograf ist toll – aber nicht jede Hochzeit braucht ein teures Fotoshooting. Vielleicht möchtet ihr lieber spontan, kreativ oder budgetbewusst feiern. Gute Nachrichten: Es gibt viele Wege, großartige Hochzeitsbilder zu bekommen, ohne dass ihr ein Vermögen ausgebt. Hier findest du **praktische Ideen, kreative Alternativen und echte Erfahrungs-Tipps**, wie du mit unserer **Fotospiel App** trotzdem eine einzigartige Hochzeitsgalerie erhältst.',
|
||||
@@ -1127,7 +1127,7 @@ QR codes are simple to set up but incredibly powerful when used intentionally. T
|
||||
],
|
||||
[
|
||||
'slug' => 'wann-hochzeitseinladungen-verschicken',
|
||||
'published_at' => '2025-12-26 15:00:00',
|
||||
'published_at' => '2026-01-16 15:00:00',
|
||||
'de' => [
|
||||
'title' => 'Wann Hochzeitseinladungen verschickt werden sollten – Der ultimative Leitfaden für perfektes Timing',
|
||||
'excerpt' => 'Planung ist alles – besonders bei einer Hochzeit. Zwischen Gästelisten, Location und Budget gerät das Thema **Einladungen** oft ins Hintertreffen. Doch das richtige Timing entscheidet, ob eure Liebsten kommen können oder schon verplant sind. In diesem Leitfaden erfährst du **wann** und **wie** du Einladungen am besten verschickst – plus clevere **Praxis‑Tipps**, wie du QR‑Codes, Fotoboxen und unsere **Fotospiel App** elegant in deine Kommunikation integrierst.',
|
||||
@@ -1145,7 +1145,7 @@ QR codes are simple to set up but incredibly powerful when used intentionally. T
|
||||
],
|
||||
[
|
||||
'slug' => 'warum-gastfotos-unverzichtbar-sind',
|
||||
'published_at' => '2026-01-17 14:00:00',
|
||||
'published_at' => '2026-02-07 14:00:00',
|
||||
'de' => [
|
||||
'title' => 'Warum Gastfotos wichtig sind – auch wenn du einen Profi-Fotografen hast',
|
||||
'excerpt' => 'Dein Hochzeitstag vergeht wie im Flug. Zwischen Vorfreude, Tränen und Tanz merkst du kaum, wie viele kleine Momente an dir vorbeiziehen. Natürlich ist ein Profi-Fotograf unverzichtbar – aber selbst der beste kann nicht überall gleichzeitig sein. **Gastfotos** ergänzen das große Ganze: Sie zeigen deine Feier aus allen Perspektiven, ungefiltert, echt und emotional.',
|
||||
@@ -1255,7 +1255,7 @@ With a bit of coordination and the Photo Game App, all these perspectives come t
|
||||
],
|
||||
[
|
||||
'slug' => 'wie-gaeste-eventmomente-festhalten',
|
||||
'published_at' => '2026-01-03 10:00:00',
|
||||
'published_at' => '2026-01-24 10:00:00',
|
||||
'de' => [
|
||||
'title' => 'Wie Gäste eure schönsten Eventmomente festhalten – und was du dafür tun musst',
|
||||
'excerpt' => 'Jedes Event lebt von seinen Gästen – sie sind die Energie, die Stimmung und am Ende die besten Geschichtenerzähler. Doch während Profis meist die großen Highlights ablichten, sind es die Gäste, die die **echten Momente** einfangen: spontanes Lachen, kleine Gesten, ehrliche Emotionen. Damit diese Schätze nicht verloren gehen, braucht es ein bisschen Organisation – und ein cleveres System, um sie zu sammeln. Hier erfährst du, wie du Gäste motivierst, aktiv zu fotografieren, und wie **unsere Fotospiel App** daraus ein unvergessliches Erlebnis für alle macht.',
|
||||
|
||||
@@ -60,7 +60,7 @@ class CouponSeeder extends Seeder
|
||||
[
|
||||
'code' => 'UPGRADE30',
|
||||
'name' => 'Upgrade 30 €',
|
||||
'description' => '30 € Nachlass als Upgrade-Anreiz von Starter auf Standard/Premium.',
|
||||
'description' => '30 € Nachlass als Upgrade-Anreiz von Starter auf Classic/Premium.',
|
||||
'type' => CouponType::FLAT,
|
||||
'amount' => 30.00,
|
||||
'currency' => 'EUR',
|
||||
@@ -77,7 +77,7 @@ class CouponSeeder extends Seeder
|
||||
[
|
||||
'code' => 'SEASON50',
|
||||
'name' => 'Hochzeits-Saison 50 €',
|
||||
'description' => 'Saisonaler 50 € Rabatt für die Hochzeitssaison auf Standard/Premium.',
|
||||
'description' => 'Saisonaler 50 € Rabatt für die Hochzeitssaison auf Classic/Premium.',
|
||||
'type' => CouponType::FLAT,
|
||||
'amount' => 50.00,
|
||||
'currency' => 'EUR',
|
||||
|
||||
@@ -36,7 +36,7 @@ class DemoEventSeeder extends Seeder
|
||||
$events = [
|
||||
[
|
||||
'slug' => 'demo-wedding-2025',
|
||||
'name' => ['de' => 'Demo Hochzeit 2025', 'en' => 'Demo Wedding 2025'],
|
||||
'name' => ['de' => 'Hochzeit von Klara & Ben', 'en' => "Klara & Ben's Wedding"],
|
||||
'description' => ['de' => 'Demo-Event', 'en' => 'Demo event'],
|
||||
'date' => Carbon::now()->addMonths(3),
|
||||
'event_type' => $weddingType,
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\Tenant;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@@ -98,7 +99,7 @@ class DemoPhotosSeeder extends Seeder
|
||||
}
|
||||
|
||||
$guestNames = $config['guest_names'];
|
||||
$photosToSeed = min($photoFiles->count(), count($guestNames));
|
||||
$photosToSeed = min($photoFiles->count(), count($guestNames), 20);
|
||||
|
||||
if ($photosToSeed === 0) {
|
||||
continue;
|
||||
@@ -143,13 +144,7 @@ class DemoPhotosSeeder extends Seeder
|
||||
$taskId = $taskIds ? $taskIds[array_rand($taskIds)] : null;
|
||||
$emotionId = $emotions->random()->id;
|
||||
|
||||
$photo = Photo::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_id' => $event->id,
|
||||
'file_path' => $destPath,
|
||||
],
|
||||
[
|
||||
$photoData = [
|
||||
'task_id' => $taskId,
|
||||
'emotion_id' => $emotionId,
|
||||
'guest_name' => $guestName,
|
||||
@@ -159,7 +154,19 @@ class DemoPhotosSeeder extends Seeder
|
||||
'metadata' => ['demo' => true],
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
]
|
||||
];
|
||||
|
||||
if (Schema::hasColumn('photos', 'status')) {
|
||||
$photoData['status'] = 'approved';
|
||||
}
|
||||
|
||||
$photo = Photo::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_id' => $event->id,
|
||||
'file_path' => $destPath,
|
||||
],
|
||||
$photoData
|
||||
);
|
||||
|
||||
PhotoLike::where('photo_id', $photo->id)->delete();
|
||||
|
||||
@@ -31,7 +31,7 @@ class PackageSeeder extends Seeder
|
||||
'max_events_per_year' => 1,
|
||||
'watermark_allowed' => false,
|
||||
'branding_allowed' => false,
|
||||
'features' => ['basic_uploads', 'limited_sharing', 'custom_tasks'],
|
||||
'features' => ['basic_uploads', 'limited_sharing', 'custom_tasks', 'live_slideshow'],
|
||||
'paddle_product_id' => 'pro_01k8jcxx2g1vj9snqbga4283ej',
|
||||
'paddle_price_id' => 'pri_01k8jcxx8qktxvqzzv0nkjjj27',
|
||||
'description' => <<<'TEXT'
|
||||
@@ -51,10 +51,10 @@ TEXT,
|
||||
],
|
||||
[
|
||||
'slug' => 'standard',
|
||||
'name' => 'Standard',
|
||||
'name' => 'Classic',
|
||||
'name_translations' => [
|
||||
'de' => 'Standard',
|
||||
'en' => 'Standard',
|
||||
'de' => 'Classic',
|
||||
'en' => 'Classic',
|
||||
],
|
||||
'type' => PackageType::ENDCUSTOMER,
|
||||
'price' => 59.00,
|
||||
@@ -151,10 +151,10 @@ TEXT,
|
||||
],
|
||||
[
|
||||
'slug' => 'm-medium-reseller',
|
||||
'name' => 'Partner Standard',
|
||||
'name' => 'Partner Classic',
|
||||
'name_translations' => [
|
||||
'de' => 'Partner Standard',
|
||||
'en' => 'Partner Standard',
|
||||
'de' => 'Partner Classic',
|
||||
'en' => 'Partner Classic',
|
||||
],
|
||||
'type' => PackageType::RESELLER,
|
||||
'included_package_slug' => 'standard',
|
||||
@@ -171,15 +171,15 @@ TEXT,
|
||||
'paddle_product_id' => 'pro_01k8jcxtrxw7jsew52jnax901q',
|
||||
'paddle_price_id' => 'pri_01k8jcxv06nsgy8ym8mnfrfm5v',
|
||||
'description' => <<<'TEXT'
|
||||
Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Standard‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
|
||||
Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Classic‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
|
||||
TEXT,
|
||||
'description_translations' => [
|
||||
'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Standard‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
|
||||
'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Standard level. Recommended to use within 24 months.',
|
||||
'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Classic‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
|
||||
'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Classic level. Recommended to use within 24 months.',
|
||||
],
|
||||
'description_table' => [
|
||||
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
|
||||
['title' => 'Inklusive Event-Level', 'value' => 'Standard'],
|
||||
['title' => 'Inklusive Event-Level', 'value' => 'Classic'],
|
||||
['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
|
||||
],
|
||||
],
|
||||
@@ -273,15 +273,15 @@ TEXT,
|
||||
'paddle_product_id' => 'pro_01k8jct3gz9ks5mg6z61q6nrxb',
|
||||
'paddle_price_id' => 'pri_01k8jcxsa8axwpjnybhjbcrb06',
|
||||
'description' => <<<'TEXT'
|
||||
Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Standard‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
|
||||
Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Classic‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
|
||||
TEXT,
|
||||
'description_translations' => [
|
||||
'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Standard‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
|
||||
'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Standard level. Recommended to use within 24 months.',
|
||||
'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Classic‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
|
||||
'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Classic level. Recommended to use within 24 months.',
|
||||
],
|
||||
'description_table' => [
|
||||
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
|
||||
['title' => 'Inklusive Event-Level', 'value' => 'Standard'],
|
||||
['title' => 'Inklusive Event-Level', 'value' => 'Classic'],
|
||||
['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -19,6 +19,7 @@ class SuperAdminSeeder extends Seeder
|
||||
'last_name' => 'Admin',
|
||||
'password' => Hash::make($password),
|
||||
'role' => 'super_admin',
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$tenantSlug = env('OWNER_TENANT_SLUG', 'owner-tenant');
|
||||
@@ -58,5 +59,9 @@ class SuperAdminSeeder extends Seeder
|
||||
if ($user->tenant_id !== $tenant->id) {
|
||||
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
||||
}
|
||||
|
||||
if (! $user->email_verified_at) {
|
||||
$user->forceFill(['email_verified_at' => now()])->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Emotion;
|
||||
use App\Models\EventType;
|
||||
use App\Models\Task;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class WeddingTasksSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$weddingType = EventType::where('slug', 'wedding')->first();
|
||||
if (! $weddingType) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Helper to resolve emotion by English name (more stable given encoding issues)
|
||||
$by = fn (string $en) => Emotion::where('name->en', $en)->first();
|
||||
|
||||
$emLove = $by('Love');
|
||||
$emJoy = $by('Joy');
|
||||
$emTouched = $by('Touched');
|
||||
$emNostalgia = $by('Nostalgia');
|
||||
$emSurprise = $by('Surprise');
|
||||
$emPride = $by('Pride');
|
||||
|
||||
$tasks = [
|
||||
// Liebe (10)
|
||||
[$emLove, 'Kuss-Foto', 'Kiss Photo', 'Macht ein romantisches Kuss-Foto.', 'Take a romantic kiss photo.', 'easy'],
|
||||
[$emLove, 'Herz mit Haenden', 'Hands Heart', 'Formt ein Herz mit euren Haenden.', 'Make a heart with your hands.', 'easy'],
|
||||
[$emLove, 'Brautstrauss im Fokus', 'Bouquet Close-up', 'Brautstrauss nah an die Kamera halten.', 'Hold the bouquet close to the camera.', 'easy'],
|
||||
[$emLove, 'Stirnkuss', 'Forehead Kiss', 'Sanfter Stirnkuss – ganz verliebt.', 'A gentle forehead kiss.', 'easy'],
|
||||
[$emLove, 'Herzensblick', 'Loving Gaze', 'Schaut euch nur in die Augen.', 'Look only into each other’s eyes.', 'easy'],
|
||||
[$emLove, 'Schleiermoment', 'Veil Moment', 'Schleier ueber beide Koepfe legen.', 'Drape the veil over both of you.', 'medium'],
|
||||
[$emLove, 'Ringnahaufnahme', 'Ring Macro', 'Zeigt eure Ringe nah an der Kamera.', 'Show your rings close to the camera.', 'easy'],
|
||||
[$emLove, 'Hand in Hand', 'Holding Hands', 'Haende greifen, Kamera im Hintergrund.', 'Hold hands with the camera behind.', 'easy'],
|
||||
[$emLove, 'Tanzschritt', 'First Dance Step', 'Ein kleiner Tanzschritt fuer das Foto.', 'A small dance step for the photo.', 'medium'],
|
||||
[$emLove, 'Kuss hinter dem Strauss', 'Peek-a-boo Kiss', 'Kuss hinter dem Brautstrauss verstecken.', 'Hide a kiss behind the bouquet.', 'easy'],
|
||||
|
||||
// Freude (10)
|
||||
[$emJoy, 'Sprung-Foto', 'Jump Photo', 'Alle springen gleichzeitig!', 'Everyone jump together!', 'medium'],
|
||||
[$emJoy, 'Lachendes Gruppenfoto', 'Laughing Group', 'Erzaehlt einen Witz und klick!', 'Tell a joke and click!', 'easy'],
|
||||
[$emJoy, 'Konfetti-Moment', 'Confetti Moment', 'Konfetti werfen (oder so tun).', 'Throw confetti (or pretend).', 'easy'],
|
||||
[$emJoy, 'Cheers!', 'Cheers!', 'Glaser anstossen in die Kamera.', 'Clink glasses toward the camera.', 'easy'],
|
||||
[$emJoy, 'Freudensprung zu zweit', 'Couple Jump', 'Brautpaar springt gemeinsam.', 'Couple jumps together.', 'medium'],
|
||||
[$emJoy, 'Luftkuesse', 'Blowing Kisses', 'Luftkuesse in Richtung Kamera.', 'Blow kisses toward the camera.', 'easy'],
|
||||
[$emJoy, 'Scherzbrillen', 'Silly Glasses', 'Accessoires aufsetzen und lachen.', 'Wear props and laugh.', 'easy'],
|
||||
[$emJoy, 'Freudige Umarmung', 'Happy Hug', 'Grosse Umarmung in der Runde.', 'Big group hug.', 'easy'],
|
||||
[$emJoy, 'Daumen hoch', 'Thumbs Up', 'Alle Daumen nach oben!', 'Thumbs up, everyone!', 'easy'],
|
||||
[$emJoy, 'Victory-Zeichen', 'Peace Sign', 'Peace-Zeichen in die Kamera.', 'Peace sign to the camera.', 'easy'],
|
||||
|
||||
// Touched (8)
|
||||
[$emTouched, 'Traenen des Gluecks', 'Tears of Joy', 'Sanftes Traenchen abtupfen.', 'Dab a happy tear.', 'easy'],
|
||||
[$emTouched, 'Eltern-Umarmung', 'Parents’ Hug', 'Umarmung mit Eltern oder Trauzeugen.', 'Hug with parents or witnesses.', 'easy'],
|
||||
[$emTouched, 'Hand auf’s Herz', 'Hand on Heart', 'Hand aufs Herz – ehrlicher Moment.', 'Hand on heart — a sincere moment.', 'easy'],
|
||||
[$emTouched, 'Danke-Geste', 'Thank You Gesture', '„Danke“-Geste in die Kamera.', 'A “thank you” gesture to the camera.', 'easy'],
|
||||
[$emTouched, 'Enger Nasenstups', 'Nose Boop', 'Stirn an Stirn, sanfter Nasenstups.', 'Forehead to forehead, a soft nose boop.', 'easy'],
|
||||
[$emTouched, 'Geliebtes Andenken', 'Keepsake', 'Ein bedeutsames Andenken zeigen.', 'Show a meaningful keepsake.', 'easy'],
|
||||
[$emTouched, 'Leise Worte', 'Whisper', 'Ein leises Kompliment ins Ohr.', 'Whisper a compliment.', 'easy'],
|
||||
[$emTouched, 'Ruhe vor dem Sturm', 'Quiet Moment', 'Augen schliessen, tief durchatmen.', 'Close eyes and take a deep breath.', 'easy'],
|
||||
|
||||
// Nostalgia (8)
|
||||
[$emNostalgia, 'Altes Foto nachstellen', 'Recreate Old Photo', 'Ein altes Familienfoto nachstellen.', 'Recreate an old family photo.', 'medium'],
|
||||
[$emNostalgia, 'Kindheits-Pose', 'Childhood Pose', 'Lieblingspose aus der Kindheit.', 'Favorite childhood pose.', 'easy'],
|
||||
[$emNostalgia, 'Erste Nachricht', 'First Message', 'Handys mit erster Nachricht zeigen.', 'Show your first message on phones.', 'medium'],
|
||||
[$emNostalgia, 'Ringbox Vintage', 'Vintage Ring Box', 'Ringbox im Vintage-Stil inszenieren.', 'Stage the vintage ring box.', 'easy'],
|
||||
[$emNostalgia, 'Familienerbstueck', 'Family Heirloom', 'Ein Familienerbstueck ins Bild.', 'Feature a family heirloom.', 'easy'],
|
||||
[$emNostalgia, 'Schwarzweiss', 'Black & White', 'Schwarzweiss-Pose fuer klassisches Foto.', 'Pose for a black & white shot.', 'easy'],
|
||||
[$emNostalgia, 'Erster Tanz (Mini)', 'Mini First Dance', 'Ein Schritt vom ersten Tanz.', 'One step of the first dance.', 'easy'],
|
||||
[$emNostalgia, 'Gastebuch-Moment', 'Guestbook Moment', 'Eintrag ins Gaestebuch festhalten.', 'Capture a guestbook entry.', 'easy'],
|
||||
|
||||
// Surprise (7)
|
||||
[$emSurprise, 'Photobomb!', 'Photobomb!', 'Ueberraschung im Hintergrund.', 'Surprise in the background.', 'easy'],
|
||||
[$emSurprise, 'Erster Blick', 'First Look', 'Reaktion beim First Look nachstellen.', 'Recreate a first-look reaction.', 'medium'],
|
||||
[$emSurprise, 'Ueberraschungs-Dip', 'Surprise Dip', 'Ueberraschender Tanz-Dip.', 'A surprise dance dip.', 'medium'],
|
||||
[$emSurprise, 'Ballon-Pop', 'Balloon Pop', 'Ballon zerplatzen (oder so tun).', 'Pop a balloon (or pretend).', 'easy'],
|
||||
[$emSurprise, 'Hutwechsel', 'Hat Swap', 'Huete/Accessoires spontan tauschen.', 'Swap hats/props on the fly.', 'easy'],
|
||||
[$emSurprise, 'Versteckspiel', 'Peekaboo', 'Hinter Deko kurz verstecken.', 'Peek from behind decor.', 'easy'],
|
||||
[$emSurprise, 'Gespiegelte Pose', 'Mirror Pose', 'Gegensaetzliche, gespiegelte Pose.', 'Opposite mirrored pose.', 'easy'],
|
||||
|
||||
// Pride (7)
|
||||
[$emPride, 'Just Married', 'Just Married', '„Just Married“-Schild zeigen.', 'Show a “Just Married” sign.', 'easy'],
|
||||
[$emPride, 'Ring zeigen', 'Show the Ring', 'Ring zur Kamera strecken.', 'Stretch ring toward the camera.', 'easy'],
|
||||
[$emPride, 'Brautkleid-Detail', 'Dress Detail', 'Lieblingsdetail am Kleid zeigen.', 'Show a favorite dress detail.', 'easy'],
|
||||
[$emPride, 'Anzug-Detail', 'Suit Detail', 'Manschette/Knopfloch zeigen.', 'Show cuff/ boutonniere.', 'easy'],
|
||||
[$emPride, 'Team Braut', 'Team Bride', '„Team Braut“-Gruppenpose.', '“Team Bride” group pose.', 'easy'],
|
||||
[$emPride, 'Team Braeutigam', 'Team Groom', '„Team Braeutigam“-Gruppenpose.', '“Team Groom” group pose.', 'easy'],
|
||||
[$emPride, 'Siegesschrei', 'Victory Cheer', 'Arme hoch, Jubel in die Kamera.', 'Arms up, cheer to the camera.', 'easy'],
|
||||
];
|
||||
|
||||
$sort = 1;
|
||||
foreach ($tasks as [$emotion, $titleDe, $titleEn, $descDe, $descEn, $difficulty]) {
|
||||
if (! $emotion) {
|
||||
continue;
|
||||
}
|
||||
Task::updateOrCreate([
|
||||
'emotion_id' => $emotion->id,
|
||||
'title->de' => $titleDe,
|
||||
], [
|
||||
'emotion_id' => $emotion->id,
|
||||
'event_type_id' => $weddingType->id,
|
||||
'title' => ['de' => $titleDe, 'en' => $titleEn],
|
||||
'description' => ['de' => $descDe, 'en' => $descEn],
|
||||
'difficulty' => $difficulty,
|
||||
'sort_order' => $sort++,
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,10 +65,8 @@ services:
|
||||
- app-code:/var/www/html
|
||||
- app-storage:/var/www/html/storage
|
||||
- app-bootstrap-cache:/var/www/html/bootstrap/cache
|
||||
- photobooth-import:/var/www/html/storage/app/photobooth
|
||||
networks:
|
||||
- default
|
||||
- photobooth-network
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
@@ -150,18 +148,6 @@ services:
|
||||
condition: service_completed_successfully
|
||||
app:
|
||||
condition: service_healthy
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.middlewares.fotospiel-https-redirect.redirectscheme.scheme=https
|
||||
- traefik.http.routers.fotospiel-http.rule=Host(`test-y0k0.fotospiel.app`)
|
||||
- traefik.http.routers.fotospiel-http.entrypoints=web
|
||||
- traefik.http.routers.fotospiel-http.middlewares=fotospiel-https-redirect
|
||||
- traefik.http.routers.fotospiel-https.rule=Host(`test-y0k0.fotospiel.app`)
|
||||
- traefik.http.routers.fotospiel-https.entrypoints=websecure
|
||||
- traefik.http.routers.fotospiel-https.tls=true
|
||||
- traefik.http.routers.fotospiel-https.service=fotospiel-web
|
||||
- traefik.http.services.fotospiel-web.loadbalancer.server.port=80
|
||||
- traefik.docker.network=dokploy-network
|
||||
volumes:
|
||||
- app-code:/var/www/html:ro
|
||||
- app-storage:/var/www/html/storage:ro
|
||||
@@ -174,41 +160,6 @@ services:
|
||||
- dokploy-network
|
||||
restart: unless-stopped
|
||||
|
||||
photobooth-ftp:
|
||||
build:
|
||||
context: ./docker/photobooth-control
|
||||
image: ${PHOTOBOOTH_CONTROL_IMAGE_REPO:-fotospiel-photobooth-control}:${PHOTOBOOTH_CONTROL_IMAGE_TAG:-latest}
|
||||
env_file:
|
||||
- path: .env
|
||||
environment:
|
||||
CONTROL_TOKEN: ${PHOTOBOOTH_CONTROL_TOKEN}
|
||||
FTP_PUBLIC_HOST: ${PHOTOBOOTH_FTP_ADDRESS:-test-y0k0.fotospiel.app}
|
||||
FTP_PORT: ${PHOTOBOOTH_FTP_PORT:-2121}
|
||||
FTP_PASSIVE_MIN: ${PHOTOBOOTH_FTP_PASV_MIN_PORT:-30000}
|
||||
FTP_PASSIVE_MAX: ${PHOTOBOOTH_FTP_PASV_MAX_PORT:-30009}
|
||||
REQUIRE_FTPS: ${PHOTOBOOTH_REQUIRE_FTPS:-0}
|
||||
PHOTOBOOTH_ROOT: /photobooth
|
||||
FTP_SYSTEM_USER: ${PHOTOBOOTH_FTP_USER:-ftpuser}
|
||||
FTP_SYSTEM_GROUP: ${PHOTOBOOTH_FTP_GROUP:-ftpgroup}
|
||||
FTP_MAX_CLIENTS: ${PHOTOBOOTH_FTP_MAX_CLIENTS:-50}
|
||||
FTP_MAX_PER_IP: ${PHOTOBOOTH_FTP_MAX_PER_IP:-10}
|
||||
volumes:
|
||||
- photobooth-import:/photobooth
|
||||
- photobooth-ftp-auth:/etc/pure-ftpd
|
||||
ports:
|
||||
- "${PHOTOBOOTH_FTP_PORT:-2121}:21"
|
||||
- "${PHOTOBOOTH_FTP_PASV_MIN_PORT:-30000}-${PHOTOBOOTH_FTP_PASV_MAX_PORT:-30009}:${PHOTOBOOTH_FTP_PASV_MIN_PORT:-30000}-${PHOTOBOOTH_FTP_PASV_MAX_PORT:-30009}"
|
||||
networks:
|
||||
- dokploy-network
|
||||
- photobooth-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/health >/dev/null 2>&1 && nc -z localhost 21"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
queue:
|
||||
image: ${APP_IMAGE_REPO:-fotospiel-app}:${APP_IMAGE_TAG:-latest}
|
||||
env_file:
|
||||
@@ -223,7 +174,6 @@ services:
|
||||
- app-bootstrap-cache:/var/www/html/bootstrap/cache
|
||||
networks:
|
||||
- default
|
||||
- photobooth-network
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
@@ -247,7 +197,6 @@ services:
|
||||
- app-bootstrap-cache:/var/www/html/bootstrap/cache
|
||||
networks:
|
||||
- default
|
||||
- photobooth-network
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
@@ -271,7 +220,6 @@ services:
|
||||
- app-bootstrap-cache:/var/www/html/bootstrap/cache
|
||||
networks:
|
||||
- default
|
||||
- photobooth-network
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
@@ -291,10 +239,8 @@ services:
|
||||
- app-code:/var/www/html
|
||||
- app-storage:/var/www/html/storage
|
||||
- app-bootstrap-cache:/var/www/html/bootstrap/cache
|
||||
- photobooth-import:/var/www/html/storage/app/photobooth
|
||||
networks:
|
||||
- default
|
||||
- photobooth-network
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
@@ -314,7 +260,6 @@ services:
|
||||
- app-bootstrap-cache:/var/www/html/bootstrap/cache
|
||||
networks:
|
||||
- default
|
||||
- photobooth-network
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
@@ -352,6 +297,17 @@ services:
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
uptime-kuma:
|
||||
image: louislam/uptime-kuma:1
|
||||
volumes:
|
||||
- uptime-kuma-data:/app/data
|
||||
ports:
|
||||
- "${UPTIME_KUMA_PORT:-3001}:3001"
|
||||
networks:
|
||||
- default
|
||||
- dokploy-network
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
app-code:
|
||||
app-storage:
|
||||
@@ -359,13 +315,10 @@ volumes:
|
||||
name: fotospiel-${APP_ENV:-prod}-storage
|
||||
app-bootstrap-cache:
|
||||
nuget-cache:
|
||||
photobooth-import:
|
||||
photobooth-ftp-auth:
|
||||
mysql-data:
|
||||
redis-data:
|
||||
uptime-kuma-data:
|
||||
|
||||
networks:
|
||||
dokploy-network:
|
||||
external: true
|
||||
photobooth-network:
|
||||
name: fotospiel-${APP_ENV:-prod}-photobooth
|
||||
|
||||
@@ -133,7 +133,16 @@ services:
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
uptime-kuma:
|
||||
image: louislam/uptime-kuma:1
|
||||
volumes:
|
||||
- uptime-kuma-data:/app/data
|
||||
ports:
|
||||
- "${UPTIME_KUMA_PORT:-3001}:3001"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
app-code:
|
||||
mysql-data:
|
||||
redis-data:
|
||||
uptime-kuma-data:
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
- `SEC-FE-01` CSP nonce utility.
|
||||
- Week 2
|
||||
- `SEC-IO-02` refresh-token management UI. *(delivered 2025-10-23)*
|
||||
- `SEC-GT-02` token analytics dashboards.
|
||||
- `SEC-API-02` incident response playbook.
|
||||
- `SEC-MS-02` streaming upload refactor.
|
||||
- `SEC-BILL-02` webhook signature freshness.
|
||||
|
||||
@@ -60,6 +60,20 @@
|
||||
- **Robots.txt**: Allow both locales; noindex for dev.
|
||||
- **Accessibility**: ARIA labels with `t()`; screen reader support for language switches.
|
||||
|
||||
### SEO Implementation Notes (Marketing)
|
||||
- **Source of tags**: `resources/js/layouts/mainWebsite.tsx` renders canonical + hreflang links for Inertia marketing pages.
|
||||
- **URL building**: `resources/js/lib/localizedPath.ts` handles locale rewrites (e.g., `/kontakt` ↔ `/contact`) and prefixing.
|
||||
- **Data inputs**: `supportedLocales`, `locale`, and `appUrl` are shared via `app/Http/Middleware/HandleInertiaRequests.php`.
|
||||
- **SSR**: Inertia SSR is disabled (`config/inertia.php`), so canonical/hreflang tags are client-rendered.
|
||||
|
||||
### Validation Checklist (Search Console + Lighthouse)
|
||||
- Verify canonical + hreflang output on key marketing pages (home, contact, packages, blog, occasions).
|
||||
- Use Search Console URL inspection to confirm rendered HTML contains canonical + hreflang tags.
|
||||
- Run Lighthouse SEO audit on both `/de/*` and `/en/*` routes (spot-check canonical + alternate links).
|
||||
|
||||
### Tests
|
||||
- **Vitest**: `resources/js/layouts/__tests__/mainWebsite.seo.test.tsx` validates canonical/hreflang output and localized slug rewrites.
|
||||
|
||||
## Migration from PHP to JSON
|
||||
- Extract keys from `resources/lang/\{locale\}/marketing.php` to `public/lang/\{locale\}/marketing.json`.
|
||||
- Consolidate: Remove duplicates; use nested objects (e.g., `{ "header": { "login": "Anmelden" } }`).
|
||||
|
||||
@@ -40,6 +40,7 @@ Ziel: Vollständige Migration zu Inertia.js für SPA-ähnliche Konsistenz, mit e
|
||||
- **Auth-Integration**: usePage().props.auth für CTAs (z.B. Login-Button im Header).
|
||||
- **Responsive Design**: Tailwind: block md:hidden für Mobile-Carousel in Packages.
|
||||
- **Fonts & Assets**: Vite-Plugin für Fonts/SVGs; preload in Layout.
|
||||
- **SEO (hreflang/canonical)**: `resources/js/layouts/mainWebsite.tsx` rendert canonical + hreflang auf Basis von `supportedLocales`, `locale`, `appUrl` und `resources/js/lib/localizedPath.ts` (Locale-Rewrites).
|
||||
- **Analytics (Matomo)**: Aktivierung via `.env` (`MATOMO_ENABLED=true`, `MATOMO_URL`, `MATOMO_SITE_ID`). `AppServiceProvider` teilt die Konfiguration als `analytics.matomo`; `MarketingLayout` rendert `MatomoTracker`, der das Snippet aus `/docs/piwik-trackingcode.txt` nur bei erteilter Analyse-Zustimmung lädt, `disableCookies` setzt und bei jedem Inertia-Navigationsevent `trackPageView` sendet. Ein lokalisierter Consent-Banner (DE/EN) übernimmt die DSGVO-konforme Einwilligung und ist über den Footer erneut erreichbar.
|
||||
- **Tests**: E2E mit Playwright (z.B. navigate to /packages, check header/footer presence).
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
title: "Troubleshooting & Incident-Playbooks"
|
||||
locale: de
|
||||
slug: admin-issue-resolution
|
||||
audience: admin
|
||||
summary: "Leitfäden für typische Admin-Vorfälle – von hängenden Uploads bis zu Billing-Sperren."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2025-02-22
|
||||
owner: reliability@fotospiel.app
|
||||
related:
|
||||
- slug: live-ops-control
|
||||
- slug: privacy-and-support
|
||||
---
|
||||
|
||||
## Upload-Vorfälle
|
||||
| Symptom | Diagnose | Lösung |
|
||||
| --- | --- | --- |
|
||||
| Warteschlange >10 Min fest | Live-Ops-Health-Widget prüfen | `php artisan media:backfill-thumbnails --tenant=XYZ` ausführen, Event neu öffnen |
|
||||
| Einzelner Gast blockiert | Geräte-Limit erreicht | Limit unter Event → Upload-Regeln erhöhen oder Gast bittet Entwürfe zu löschen |
|
||||
| Fotos ohne EXIF | Gast importiert Screenshots | Kein Fehler; Hinweis geben, dass EXIF optional ist |
|
||||
|
||||
## Zugriffsprobleme
|
||||
- **Admin kommt nicht rein**: Prüfen, ob Einladung akzeptiert wurde; über *Team → Einladung erneut senden* resetten. Bei SSO Pflicht Zuordnung kontrollieren.
|
||||
- **Gast kann nicht beitreten**: Event-Status muss *Published* sein; direkten Join-Link `https://app.fotospiel.com/join/<code>` teilen.
|
||||
|
||||
## Billing & Quoten
|
||||
- Paddle-Webhook-Fehler sperrt Uploads: `storage/logs/paddle.log` prüfen, Webhook im Paddle-Dashboard erneut senden, anschließend Abo-Status toggeln.
|
||||
- Speicher zu 90 % voll: Archivierung vorziehen oder Add-on im Paddle-Kundenportal buchen.
|
||||
|
||||
## Kommunikationsvorlagen
|
||||
Nutze die vorformulierten Antworten in `docs/content/fotospiel_howto_artikel_detailliert.md`, um Messaging konsistent zu halten.
|
||||
|
||||
### Weitere Hilfe
|
||||
Eskalation an reliability@fotospiel.app mit Event-ID, Kunde und Zeitstempel. Screenshots/Logs anhängen, wenn verfügbar.
|
||||
35
docs/help/de/admin/billing-packages-exports.md
Normal file
35
docs/help/de/admin/billing-packages-exports.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
title: "Pakete, Abrechnung & Exporte"
|
||||
locale: de
|
||||
slug: billing-packages-exports
|
||||
audience: admin
|
||||
summary: "Paketlimits prüfen, Add-ons kaufen und Datenexporte anfordern."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2026-01-23
|
||||
owner: success@fotospiel.app
|
||||
related:
|
||||
- slug: post-event-wrapup
|
||||
- slug: tenant-dashboard-overview
|
||||
---
|
||||
|
||||
## Pakete & Limits
|
||||
- Verbleibende Events, Gäste, Fotos und Galerietage prüfen.
|
||||
- Bei Bedarf Paket upgraden oder Add‑ons kaufen.
|
||||
|
||||
## Billing‑Portal
|
||||
- Zahlungsdaten oder Rechnungen im Portal verwalten.
|
||||
- Für Upgrades den Paket‑Shop nutzen.
|
||||
|
||||
## Datenexporte
|
||||
- Tenant‑ oder Event‑Export anfordern.
|
||||
- Größere Exporte brauchen etwas Zeit; Liste aktualisieren.
|
||||
|
||||
## Retention‑Hinweis
|
||||
- Galerietage prüfen, damit der Zugriff nicht abläuft.
|
||||
- Für Archivierung und Compliance `post-event-wrapup` verwenden.
|
||||
|
||||
### Weitere Hilfe
|
||||
`post-event-wrapup` für Aufgaben nach dem Event.
|
||||
40
docs/help/de/admin/control-room-moderation.md
Normal file
40
docs/help/de/admin/control-room-moderation.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: "Control Room: Moderation & Queue"
|
||||
locale: de
|
||||
slug: control-room-moderation
|
||||
audience: admin
|
||||
summary: "Uploads moderieren, Highlights setzen und die Live-Queue steuern."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2026-01-23
|
||||
owner: ops@fotospiel.app
|
||||
related:
|
||||
- slug: event-prep-checklist
|
||||
- slug: live-show-setup
|
||||
---
|
||||
|
||||
## Wann nutze ich diese Seite?
|
||||
Nutze den Control Room, wenn du Uploads prüfen, Highlights setzen oder die Live-Show-Queue steuern musst.
|
||||
|
||||
## Moderation im Überblick
|
||||
- **Freigeben** veröffentlicht das Foto in der Galerie.
|
||||
- **Ausblenden** entfernt es sofort aus der Galerie.
|
||||
- **Highlight** markiert das Foto als Featured-Inhalt.
|
||||
|
||||
## Filter & Status
|
||||
- **Offen** hilft, die Warteschlange schnell abzuarbeiten.
|
||||
- **Highlights** prüfen, ob markierte Inhalte passen.
|
||||
- **Alle** nur bei Bedarf für Audits älterer Uploads.
|
||||
|
||||
## Live-Show-Queue
|
||||
- Fotos freigeben, wenn die Live-Show moderiert wird.
|
||||
- Einträge entfernen, die nicht auf der Leinwand erscheinen sollen.
|
||||
|
||||
### Tipps
|
||||
- Control Room während des Events auf einem Team-Gerät geöffnet lassen.
|
||||
- Wenn die Queue wächst, Moderation straffen oder Effekte reduzieren.
|
||||
|
||||
### Weitere Hilfe
|
||||
`live-show-setup` für die Player-Einrichtung oder `event-prep-checklist` für die Vorbereitung.
|
||||
35
docs/help/de/admin/event-branding-assets.md
Normal file
35
docs/help/de/admin/event-branding-assets.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
title: "Branding & Assets"
|
||||
locale: de
|
||||
slug: event-branding-assets
|
||||
audience: admin
|
||||
summary: "Logos hochladen, Farben setzen und Wasserzeichen verwalten."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2026-01-23
|
||||
owner: onboarding@fotospiel.app
|
||||
related:
|
||||
- slug: event-prep-checklist
|
||||
- slug: tenant-dashboard-overview
|
||||
---
|
||||
|
||||
## Was du anpassen kannst
|
||||
- **Coverbild** und **Logo** für den Gast‑Startscreen.
|
||||
- **Primärfarbe** und **Akzent** für Buttons und Highlights.
|
||||
- **Begrüßungstext** pro Sprache.
|
||||
- **Wasserzeichen** für exportierte Fotos.
|
||||
|
||||
## Empfohlener Ablauf
|
||||
1. Logo und Coverbild zuerst hochladen.
|
||||
2. Farben passend zum Event einstellen.
|
||||
3. Vorschau prüfen und Texte anpassen.
|
||||
4. Wasserzeichen nur aktivieren, wenn vertraglich nötig.
|
||||
|
||||
### Tipps
|
||||
- Hoher Kontrast sorgt für bessere Lesbarkeit.
|
||||
- Begrüßungstext kurz halten (1–2 Sätze).
|
||||
|
||||
### Weitere Hilfe
|
||||
Event-Vorbereitung: Checkliste für die Vorbereitung oder Event-Dashboard im Überblick für den Status.
|
||||
@@ -1,38 +1,45 @@
|
||||
---
|
||||
title: "Checkliste Event-Vorbereitung"
|
||||
title: "Event-Vorbereitung: Checkliste"
|
||||
locale: de
|
||||
slug: event-prep-checklist
|
||||
audience: admin
|
||||
summary: "48-Stunden-Countdown, damit Geräte, Gäste und Automationen ready sind, bevor es losgeht."
|
||||
summary: "Die wichtigsten Schritte zur Event-Vorbereitung vor dem Start."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2025-02-22
|
||||
last_reviewed_at: 2026-01-23
|
||||
owner: ops@fotospiel.app
|
||||
related:
|
||||
- slug: live-ops-control
|
||||
- slug: post-event-wrapup
|
||||
- slug: tenant-dashboard-overview
|
||||
- slug: event-settings
|
||||
- slug: event-branding-assets
|
||||
- slug: event-tasks-setup
|
||||
- slug: guest-access-qr
|
||||
- slug: control-room-moderation
|
||||
- slug: live-show-setup
|
||||
---
|
||||
|
||||
## 48–24 Stunden vorher
|
||||
- [ ] Event in der Admin-App mit korrekter Zeitzone + Aufbewahrungsfrist anlegen.
|
||||
- [ ] Titelbild (1200×630) hochladen und Übersetzungen für Titel/Beschreibung prüfen.
|
||||
- [ ] Gästelisten importieren oder QR-Badges erzeugen.
|
||||
- [ ] Push-Vorlagen testen (Reminder, Achievement-Freischaltung).
|
||||
- [ ] **Event anlegen** mit korrektem Datum, Zeitzone und Aufbewahrung.
|
||||
- [ ] **Branding hochladen** (Cover, Logo, Begrüßungstext) in allen Sprachen.
|
||||
- [ ] **Aufgaben definieren**: Paket wählen, Aufgaben anpassen, Emotionen/Sammlungen setzen.
|
||||
- [ ] **Gästezugang vorbereiten**: QR-Code, Join-Link oder Einladungen.
|
||||
|
||||
## 24–2 Stunden vorher
|
||||
- [ ] `tenant:attach-demo-event` im Staging ausführen, um den Ablauf mit dem Team zu proben.
|
||||
- [ ] Join-QR nahe Eingang und Fotoboxen ausdrucken oder anzeigen.
|
||||
- [ ] WLAN-SSID/Passwort-Beschilderung vorbereiten.
|
||||
- [ ] Moderationsregeln mit Kundenvertrag abgleichen (z. B. explizite Inhalte blocken, Freigabe nötig).
|
||||
- [ ] Paddle/RevenueCat-Status prüfen (alle Ampeln auf Grün).
|
||||
- [ ] **Event veröffentlichen**, sobald alles freigegeben ist.
|
||||
- [ ] **Uploads testen** mit einem Gerät (Kamera-Freigabe + erster Upload).
|
||||
- [ ] **Moderation prüfen** (Sichtbarkeit, Freigabe-Regeln).
|
||||
- [ ] **Live-Show einstellen**, falls eine Leinwand genutzt wird.
|
||||
|
||||
## Letzte 2 Stunden
|
||||
- [ ] Demodaten aus dem Live-Event entfernen.
|
||||
- [ ] Gäste-App auf Testgeräten öffnen und den Schnellstart durchspielen.
|
||||
- [ ] Live-Ops-Ansicht auf Tablet/Laptop in Bühnennähe starten.
|
||||
- [ ] Team zu Eskalationswegen briefen (Supportkontakte, Ersatzgeräte, Foto-Guidelines).
|
||||
- [ ] **QR aushängen** an Eingängen und Fotobooth-Punkten.
|
||||
- [ ] **Control Room öffnen** auf einem Team-Gerät bei hohem Upload-Volumen.
|
||||
- [ ] **Team briefen** für sensible Inhalte oder Support-Anfragen.
|
||||
|
||||
### Tipps
|
||||
- Das Event bleibt bis zur Freigabe im Entwurf.
|
||||
- Mit einem Testgast den Ablauf einmal komplett durchspielen.
|
||||
|
||||
### Weitere Hilfe
|
||||
Siehe `live-ops-control` für Echtzeit-Monitoring oder melde dich bei ops@fotospiel.app.
|
||||
Control Room für die Queue oder Event-Dashboard im Überblick für Status-Checks.
|
||||
|
||||
35
docs/help/de/admin/event-settings.md
Normal file
35
docs/help/de/admin/event-settings.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
title: "Event-Einstellungen"
|
||||
locale: de
|
||||
slug: event-settings
|
||||
audience: admin
|
||||
summary: "Event-Basics, Veröffentlichungsstatus und Upload-Regeln anpassen."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2026-01-23
|
||||
owner: onboarding@fotospiel.app
|
||||
related:
|
||||
- slug: event-prep-checklist
|
||||
- slug: tenant-dashboard-overview
|
||||
---
|
||||
|
||||
## Grunddaten
|
||||
- **Name, Datum, Ort** müssen stimmen – sie erscheinen in Exporten.
|
||||
- **Event-Typ** hilft bei Vorlagen und Reporting.
|
||||
|
||||
## Status & Sichtbarkeit
|
||||
- **Veröffentlicht**: Gäste können beitreten.
|
||||
- **Entwurf**: für die Vorbereitung, kein Gästezugang.
|
||||
|
||||
## Upload-Regeln
|
||||
- **Auto‑Freigabe** veröffentlicht Uploads sofort.
|
||||
- **Fotoaufgaben-Modus** steuert, ob Gäste Aufgaben sehen.
|
||||
|
||||
### Tipps
|
||||
- Event im Entwurf lassen, bis Branding und Aufgaben fertig sind.
|
||||
- Bei Moderationsbedarf Auto‑Freigabe deaktivieren.
|
||||
|
||||
### Weitere Hilfe
|
||||
Event-Vorbereitung: Checkliste für den Ablauf oder Gästezugang & QR für die Freigabe.
|
||||
38
docs/help/de/admin/event-tasks-setup.md
Normal file
38
docs/help/de/admin/event-tasks-setup.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: "Event-Aufgaben & Sammlungen"
|
||||
locale: de
|
||||
slug: event-tasks-setup
|
||||
audience: admin
|
||||
summary: "Fotoaufgaben, Aufgabenbibliothek, Emotionen und Sammlungen für Gäste konfigurieren."
|
||||
version_introduced: 2025.4
|
||||
requires_app_version: "^3.2.0"
|
||||
status: draft
|
||||
translation_state: aligned
|
||||
last_reviewed_at: 2026-01-23
|
||||
owner: onboarding@fotospiel.app
|
||||
related:
|
||||
- slug: event-prep-checklist
|
||||
- slug: tenant-dashboard-overview
|
||||
---
|
||||
|
||||
## Wann nutze ich diese Seite?
|
||||
Hier stellst du die Aufgaben zusammen, die Gäste im Event sehen und erfüllen sollen.
|
||||
|
||||
## Aufgaben-Tab
|
||||
- Zugewiesene Aufgaben prüfen und Titel/Beschreibung anpassen.
|
||||
- Aufgaben entfernen, die nicht zum Event passen.
|
||||
|
||||
## Aufgabenbibliothek
|
||||
- Paket importieren, um eine kuratierte Basis zu erhalten.
|
||||
- Eigene Aufgaben für besondere Momente hinzufügen.
|
||||
|
||||
## Emotionen & Sammlungen
|
||||
- **Emotionen** helfen beim Taggen der Stimmung (z. B. fröhlich, emotional, wild).
|
||||
- **Sammlungen** gruppieren Aufgaben nach Themen (z. B. Zeremonie, Party, Familie).
|
||||
|
||||
### Tipps
|
||||
- Aufgaben kurz und konkret formulieren.
|
||||
- 12–20 Aufgaben reichen meist aus.
|
||||
|
||||
### Weitere Hilfe
|
||||
Event-Vorbereitung: Checkliste für die Vorbereitung oder Event-Dashboard im Überblick für den Status.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user