first implementation of tamagui mobile pages
This commit is contained in:
4216
.tamagui/tamagui.config.cjs
Normal file
4216
.tamagui/tamagui.config.cjs
Normal file
File diff suppressed because one or more lines are too long
476156
.tamagui/tamagui.config.json
Normal file
476156
.tamagui/tamagui.config.json
Normal file
File diff suppressed because it is too large
Load Diff
1022
.tamagui/tamaguibutton-components.config.cjs
Normal file
1022
.tamagui/tamaguibutton-components.config.cjs
Normal file
File diff suppressed because it is too large
Load Diff
354
.tamagui/tamaguistacks-components.config.cjs
Normal file
354
.tamagui/tamaguistacks-components.config.cjs
Normal file
@@ -0,0 +1,354 @@
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||
// If the importer is in node compatibility mode or this is not an ESM
|
||||
// file that has been converted to a CommonJS file using a Babel-
|
||||
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||
mod
|
||||
));
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
|
||||
// node_modules/@tamagui/stacks/dist/esm/index.mjs
|
||||
var esm_exports = {};
|
||||
__export(esm_exports, {
|
||||
ButtonNestingContext: () => ButtonNestingContext,
|
||||
SizableStack: () => SizableStack,
|
||||
ThemeableStack: () => ThemeableStack,
|
||||
XStack: () => XStack,
|
||||
YStack: () => YStack,
|
||||
ZStack: () => ZStack,
|
||||
fullscreenStyle: () => fullscreenStyle,
|
||||
themeableVariants: () => themeableVariants
|
||||
});
|
||||
module.exports = __toCommonJS(esm_exports);
|
||||
|
||||
// node_modules/@tamagui/stacks/dist/esm/Stacks.mjs
|
||||
var import_core2 = require("@tamagui/core");
|
||||
|
||||
// node_modules/@tamagui/stacks/dist/esm/getElevation.mjs
|
||||
var import_core = require("@tamagui/core");
|
||||
var getElevation = /* @__PURE__ */ __name((size, extras) => {
|
||||
if (!size) return;
|
||||
const {
|
||||
tokens
|
||||
} = extras, token = tokens.size[size], sizeNum = (0, import_core.isVariable)(token) ? +token.val : size;
|
||||
return getSizedElevation(sizeNum, extras);
|
||||
}, "getElevation");
|
||||
var getSizedElevation = /* @__PURE__ */ __name((val, {
|
||||
theme,
|
||||
tokens
|
||||
}) => {
|
||||
let num = 0;
|
||||
if (val === true) {
|
||||
const val2 = (0, import_core.getVariableValue)(tokens.size.true);
|
||||
typeof val2 == "number" ? num = val2 : num = 10;
|
||||
} else num = +val;
|
||||
if (num === 0) return;
|
||||
const [height, shadowRadius] = [Math.round(num / 4 + 1), Math.round(num / 2 + 2)];
|
||||
return {
|
||||
shadowColor: theme.shadowColor,
|
||||
shadowRadius,
|
||||
shadowOffset: {
|
||||
height,
|
||||
width: 0
|
||||
},
|
||||
...import_core.isAndroid ? {
|
||||
elevationAndroid: 2 * height
|
||||
} : {}
|
||||
};
|
||||
}, "getSizedElevation");
|
||||
|
||||
// node_modules/@tamagui/stacks/dist/esm/Stacks.mjs
|
||||
var fullscreenStyle = {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0
|
||||
};
|
||||
var getInset = /* @__PURE__ */ __name((val) => val && typeof val == "object" ? val : {
|
||||
top: val,
|
||||
left: val,
|
||||
bottom: val,
|
||||
right: val
|
||||
}, "getInset");
|
||||
var variants = {
|
||||
fullscreen: {
|
||||
true: fullscreenStyle
|
||||
},
|
||||
elevation: {
|
||||
"...size": getElevation,
|
||||
":number": getElevation
|
||||
},
|
||||
inset: getInset
|
||||
};
|
||||
var YStack = (0, import_core2.styled)(import_core2.View, {
|
||||
flexDirection: "column",
|
||||
variants
|
||||
});
|
||||
YStack.displayName = "YStack";
|
||||
var XStack = (0, import_core2.styled)(import_core2.View, {
|
||||
flexDirection: "row",
|
||||
variants
|
||||
});
|
||||
XStack.displayName = "XStack";
|
||||
var ZStack = (0, import_core2.styled)(YStack, {
|
||||
position: "relative"
|
||||
}, {
|
||||
neverFlatten: true,
|
||||
isZStack: true
|
||||
});
|
||||
ZStack.displayName = "ZStack";
|
||||
|
||||
// node_modules/@tamagui/stacks/dist/esm/SizableStack.mjs
|
||||
var import_core3 = require("@tamagui/core");
|
||||
|
||||
// node_modules/@tamagui/get-token/dist/esm/index.mjs
|
||||
var import_web = require("@tamagui/core");
|
||||
var defaultOptions = {
|
||||
shift: 0,
|
||||
bounds: [0]
|
||||
};
|
||||
var getSpace = /* @__PURE__ */ __name((space, options) => getTokenRelative("space", space, options), "getSpace");
|
||||
var cacheVariables = {};
|
||||
var cacheWholeVariables = {};
|
||||
var cacheKeys = {};
|
||||
var cacheWholeKeys = {};
|
||||
var stepTokenUpOrDown = /* @__PURE__ */ __name((type, current, options = defaultOptions) => {
|
||||
const tokens = (0, import_web.getTokens)({
|
||||
prefixed: true
|
||||
})[type];
|
||||
if (!(type in cacheVariables)) {
|
||||
cacheKeys[type] = [], cacheVariables[type] = [], cacheWholeKeys[type] = [], cacheWholeVariables[type] = [];
|
||||
const sorted = Object.keys(tokens).map((k) => tokens[k]).sort((a, b) => a.val - b.val);
|
||||
for (const token of sorted) cacheKeys[type].push(token.key), cacheVariables[type].push(token);
|
||||
const sortedExcludingHalfSteps = sorted.filter((x) => !x.key.endsWith(".5"));
|
||||
for (const token of sortedExcludingHalfSteps) cacheWholeKeys[type].push(token.key), cacheWholeVariables[type].push(token);
|
||||
}
|
||||
const isString = typeof current == "string", tokensOrdered = (options.excludeHalfSteps ? isString ? cacheWholeKeys : cacheWholeVariables : isString ? cacheKeys : cacheVariables)[type], min = options.bounds?.[0] ?? 0, max = options.bounds?.[1] ?? tokensOrdered.length - 1, currentIndex = tokensOrdered.indexOf(current);
|
||||
let shift = options.shift || 0;
|
||||
shift && (current === "$true" || (0, import_web.isVariable)(current) && current.name === "true") && (shift += shift > 0 ? 1 : -1);
|
||||
const index = Math.min(max, Math.max(min, currentIndex + shift)), found = tokensOrdered[index];
|
||||
return (typeof found == "string" ? tokens[found] : found) || tokens.$true;
|
||||
}, "stepTokenUpOrDown");
|
||||
var getTokenRelative = stepTokenUpOrDown;
|
||||
|
||||
// node_modules/@tamagui/get-button-sized/dist/esm/index.mjs
|
||||
var getButtonSized = /* @__PURE__ */ __name((val, {
|
||||
tokens,
|
||||
props
|
||||
}) => {
|
||||
if (!val || props.circular) return;
|
||||
if (typeof val == "number") return {
|
||||
paddingHorizontal: val * 0.25,
|
||||
height: val,
|
||||
borderRadius: props.circular ? 1e5 : val * 0.2
|
||||
};
|
||||
const xSize = getSpace(val), radiusToken = tokens.radius[val] ?? tokens.radius.$true;
|
||||
return {
|
||||
paddingHorizontal: xSize,
|
||||
height: val,
|
||||
borderRadius: props.circular ? 1e5 : radiusToken
|
||||
};
|
||||
}, "getButtonSized");
|
||||
|
||||
// node_modules/@tamagui/stacks/dist/esm/variants.mjs
|
||||
var elevate = {
|
||||
true: /* @__PURE__ */ __name((_, extras) => getElevation(extras.props.size, extras), "true")
|
||||
};
|
||||
var bordered = /* @__PURE__ */ __name((val, {
|
||||
props
|
||||
}) => ({
|
||||
// TODO size it with size in '...size'
|
||||
borderWidth: typeof val == "number" ? val : 1,
|
||||
borderColor: "$borderColor",
|
||||
...props.hoverTheme && {
|
||||
hoverStyle: {
|
||||
borderColor: "$borderColorHover"
|
||||
}
|
||||
},
|
||||
...props.pressTheme && {
|
||||
pressStyle: {
|
||||
borderColor: "$borderColorPress"
|
||||
}
|
||||
},
|
||||
...props.focusTheme && {
|
||||
focusStyle: {
|
||||
borderColor: "$borderColorFocus"
|
||||
}
|
||||
}
|
||||
}), "bordered");
|
||||
var padded = {
|
||||
true: /* @__PURE__ */ __name((_, extras) => {
|
||||
const {
|
||||
tokens,
|
||||
props
|
||||
} = extras;
|
||||
return {
|
||||
padding: tokens.space[props.size] || tokens.space.$true
|
||||
};
|
||||
}, "true")
|
||||
};
|
||||
var radiused = {
|
||||
true: /* @__PURE__ */ __name((_, extras) => {
|
||||
const {
|
||||
tokens,
|
||||
props
|
||||
} = extras;
|
||||
return {
|
||||
borderRadius: tokens.radius[props.size] || tokens.radius.$true
|
||||
};
|
||||
}, "true")
|
||||
};
|
||||
var circularStyle = {
|
||||
borderRadius: 1e5,
|
||||
padding: 0
|
||||
};
|
||||
var circular = {
|
||||
true: /* @__PURE__ */ __name((_, {
|
||||
props,
|
||||
tokens
|
||||
}) => {
|
||||
if (!("size" in props)) return circularStyle;
|
||||
const size = typeof props.size == "number" ? props.size : tokens.size[props.size];
|
||||
return {
|
||||
...circularStyle,
|
||||
width: size,
|
||||
height: size,
|
||||
maxWidth: size,
|
||||
maxHeight: size,
|
||||
minWidth: size,
|
||||
minHeight: size
|
||||
};
|
||||
}, "true")
|
||||
};
|
||||
var hoverTheme = {
|
||||
true: {
|
||||
hoverStyle: {
|
||||
backgroundColor: "$backgroundHover",
|
||||
borderColor: "$borderColorHover"
|
||||
}
|
||||
},
|
||||
false: {}
|
||||
};
|
||||
var pressTheme = {
|
||||
true: {
|
||||
cursor: "pointer",
|
||||
pressStyle: {
|
||||
backgroundColor: "$backgroundPress",
|
||||
borderColor: "$borderColorPress"
|
||||
}
|
||||
},
|
||||
false: {}
|
||||
};
|
||||
var focusTheme = {
|
||||
true: {
|
||||
focusStyle: {
|
||||
backgroundColor: "$backgroundFocus",
|
||||
borderColor: "$borderColorFocus"
|
||||
}
|
||||
},
|
||||
false: {}
|
||||
};
|
||||
|
||||
// node_modules/@tamagui/stacks/dist/esm/SizableStack.mjs
|
||||
var SizableStack = (0, import_core3.styled)(XStack, {
|
||||
name: "SizableStack",
|
||||
variants: {
|
||||
unstyled: {
|
||||
true: {
|
||||
hoverTheme: false,
|
||||
pressTheme: false,
|
||||
focusTheme: false,
|
||||
elevate: false,
|
||||
bordered: false
|
||||
}
|
||||
},
|
||||
hoverTheme,
|
||||
pressTheme,
|
||||
focusTheme,
|
||||
circular,
|
||||
elevate,
|
||||
bordered,
|
||||
size: {
|
||||
"...size": /* @__PURE__ */ __name((val, extras) => getButtonSized(val, extras), "...size")
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// node_modules/@tamagui/stacks/dist/esm/ThemeableStack.mjs
|
||||
var import_core4 = require("@tamagui/core");
|
||||
var chromelessStyle = {
|
||||
backgroundColor: "transparent",
|
||||
borderColor: "transparent",
|
||||
shadowColor: "transparent",
|
||||
hoverStyle: {
|
||||
borderColor: "transparent"
|
||||
}
|
||||
};
|
||||
var themeableVariants = {
|
||||
backgrounded: {
|
||||
true: {
|
||||
backgroundColor: "$background"
|
||||
}
|
||||
},
|
||||
radiused,
|
||||
hoverTheme,
|
||||
pressTheme,
|
||||
focusTheme,
|
||||
circular,
|
||||
padded,
|
||||
elevate,
|
||||
bordered,
|
||||
transparent: {
|
||||
true: {
|
||||
backgroundColor: "transparent"
|
||||
}
|
||||
},
|
||||
chromeless: {
|
||||
true: chromelessStyle,
|
||||
all: {
|
||||
...chromelessStyle,
|
||||
hoverStyle: chromelessStyle,
|
||||
pressStyle: chromelessStyle,
|
||||
focusStyle: chromelessStyle
|
||||
}
|
||||
}
|
||||
};
|
||||
var ThemeableStack = (0, import_core4.styled)(YStack, {
|
||||
variants: themeableVariants
|
||||
});
|
||||
|
||||
// node_modules/@tamagui/stacks/dist/esm/NestingContext.mjs
|
||||
var import_react = __toESM(require("react"), 1);
|
||||
var ButtonNestingContext = import_react.default.createContext(false);
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
ButtonNestingContext,
|
||||
SizableStack,
|
||||
ThemeableStack,
|
||||
XStack,
|
||||
YStack,
|
||||
ZStack,
|
||||
fullscreenStyle,
|
||||
themeableVariants
|
||||
});
|
||||
271
.tamagui/tamaguitext-components.config.cjs
Normal file
271
.tamagui/tamaguitext-components.config.cjs
Normal file
@@ -0,0 +1,271 @@
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||
// If the importer is in node compatibility mode or this is not an ESM
|
||||
// file that has been converted to a CommonJS file using a Babel-
|
||||
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||
mod
|
||||
));
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
|
||||
// node_modules/@tamagui/text/dist/esm/index.mjs
|
||||
var esm_exports = {};
|
||||
__export(esm_exports, {
|
||||
H1: () => H1,
|
||||
H2: () => H2,
|
||||
H3: () => H3,
|
||||
H4: () => H4,
|
||||
H5: () => H5,
|
||||
H6: () => H6,
|
||||
Heading: () => Heading,
|
||||
Paragraph: () => Paragraph,
|
||||
SizableText: () => SizableText2,
|
||||
wrapChildrenInText: () => wrapChildrenInText
|
||||
});
|
||||
module.exports = __toCommonJS(esm_exports);
|
||||
|
||||
// node_modules/@tamagui/constants/dist/esm/constants.mjs
|
||||
var import_react = __toESM(require("react"), 1);
|
||||
var IS_REACT_19 = typeof import_react.default.use < "u";
|
||||
var isWeb = true;
|
||||
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 isIos = process.env.TEST_NATIVE_PLATFORM === "ios";
|
||||
|
||||
// node_modules/@tamagui/get-font-sized/dist/esm/index.mjs
|
||||
var import_web = require("@tamagui/core");
|
||||
var getFontSized = /* @__PURE__ */ __name((sizeTokenIn = "$true", {
|
||||
font,
|
||||
fontFamily,
|
||||
props
|
||||
}) => {
|
||||
if (!font) return {
|
||||
fontSize: sizeTokenIn
|
||||
};
|
||||
const sizeToken = sizeTokenIn === "$true" ? getDefaultSizeToken(font) : sizeTokenIn, style = {}, fontSize = font.size[sizeToken], lineHeight = font.lineHeight?.[sizeToken], fontWeight = font.weight?.[sizeToken], letterSpacing = font.letterSpacing?.[sizeToken], textTransform = font.transform?.[sizeToken], fontStyle = props.fontStyle ?? font.style?.[sizeToken], color = props.color ?? font.color?.[sizeToken];
|
||||
return fontStyle && (style.fontStyle = fontStyle), textTransform && (style.textTransform = textTransform), fontFamily && (style.fontFamily = fontFamily), fontWeight && (style.fontWeight = fontWeight), letterSpacing && (style.letterSpacing = letterSpacing), fontSize && (style.fontSize = fontSize), lineHeight && (style.lineHeight = lineHeight), color && (style.color = color), process.env.NODE_ENV === "development" && props.debug && props.debug === "verbose" && (console.groupCollapsed(" \u{1F539} getFontSized", sizeTokenIn, sizeToken), isClient && console.info({
|
||||
style,
|
||||
props,
|
||||
font
|
||||
}), console.groupEnd()), style;
|
||||
}, "getFontSized");
|
||||
var SizableText = (0, import_web.styled)(import_web.Text, {
|
||||
name: "SizableText",
|
||||
fontFamily: "$body",
|
||||
variants: {
|
||||
size: {
|
||||
"...fontSize": getFontSized
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "$true"
|
||||
}
|
||||
});
|
||||
var cache = /* @__PURE__ */ new WeakMap();
|
||||
function getDefaultSizeToken(font) {
|
||||
if (typeof font == "object" && cache.has(font)) return cache.get(font);
|
||||
const sizeTokens = "$true" in font.size ? font.size : (0, import_web.getTokens)().size, sizeDefault = sizeTokens.$true, sizeDefaultSpecific = sizeDefault ? Object.keys(sizeTokens).find((x) => x !== "$true" && sizeTokens[x].val === sizeDefault.val) : null;
|
||||
return !sizeDefault || !sizeDefaultSpecific ? (process.env.NODE_ENV === "development" && console.warn(`No default size is set in your tokens for the "true" key, fonts will be inconsistent.
|
||||
|
||||
Fix this by having consistent tokens across fonts and sizes and setting a true key for your size tokens, or
|
||||
set true keys for all your font tokens: "size", "lineHeight", "fontStyle", etc.`), Object.keys(font.size)[3]) : (cache.set(font, sizeDefaultSpecific), sizeDefaultSpecific);
|
||||
}
|
||||
__name(getDefaultSizeToken, "getDefaultSizeToken");
|
||||
|
||||
// node_modules/@tamagui/text/dist/esm/SizableText.mjs
|
||||
var import_web2 = require("@tamagui/core");
|
||||
var SizableText2 = (0, import_web2.styled)(import_web2.Text, {
|
||||
name: "SizableText",
|
||||
fontFamily: "$body",
|
||||
variants: {
|
||||
unstyled: {
|
||||
false: {
|
||||
size: "$true",
|
||||
color: "$color"
|
||||
}
|
||||
},
|
||||
size: getFontSized
|
||||
},
|
||||
defaultVariants: {
|
||||
unstyled: process.env.TAMAGUI_HEADLESS === "1"
|
||||
}
|
||||
});
|
||||
SizableText2.staticConfig.variants.fontFamily = {
|
||||
"...": /* @__PURE__ */ __name((_val, extras) => {
|
||||
const sizeProp = extras.props.size, fontSizeProp = extras.props.fontSize, size = sizeProp === "$true" && fontSizeProp ? fontSizeProp : extras.props.size || "$true";
|
||||
return getFontSized(size, extras);
|
||||
}, "...")
|
||||
};
|
||||
|
||||
// node_modules/@tamagui/text/dist/esm/Paragraph.mjs
|
||||
var import_web3 = require("@tamagui/core");
|
||||
var Paragraph = (0, import_web3.styled)(SizableText2, {
|
||||
name: "Paragraph",
|
||||
tag: "p",
|
||||
userSelect: "auto",
|
||||
color: "$color",
|
||||
size: "$true",
|
||||
whiteSpace: "normal"
|
||||
});
|
||||
|
||||
// node_modules/@tamagui/text/dist/esm/Headings.mjs
|
||||
var import_web4 = require("@tamagui/core");
|
||||
var Heading = (0, import_web4.styled)(Paragraph, {
|
||||
tag: "span",
|
||||
name: "Heading",
|
||||
accessibilityRole: "header",
|
||||
fontFamily: "$heading",
|
||||
size: "$8",
|
||||
margin: 0
|
||||
});
|
||||
var H1 = (0, import_web4.styled)(Heading, {
|
||||
name: "H1",
|
||||
tag: "h1",
|
||||
variants: {
|
||||
unstyled: {
|
||||
false: {
|
||||
size: "$10"
|
||||
}
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
unstyled: process.env.TAMAGUI_HEADLESS === "1"
|
||||
}
|
||||
});
|
||||
var H2 = (0, import_web4.styled)(Heading, {
|
||||
name: "H2",
|
||||
tag: "h2",
|
||||
variants: {
|
||||
unstyled: {
|
||||
false: {
|
||||
size: "$9"
|
||||
}
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
unstyled: process.env.TAMAGUI_HEADLESS === "1"
|
||||
}
|
||||
});
|
||||
var H3 = (0, import_web4.styled)(Heading, {
|
||||
name: "H3",
|
||||
tag: "h3",
|
||||
variants: {
|
||||
unstyled: {
|
||||
false: {
|
||||
size: "$8"
|
||||
}
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
unstyled: process.env.TAMAGUI_HEADLESS === "1"
|
||||
}
|
||||
});
|
||||
var H4 = (0, import_web4.styled)(Heading, {
|
||||
name: "H4",
|
||||
tag: "h4",
|
||||
variants: {
|
||||
unstyled: {
|
||||
false: {
|
||||
size: "$7"
|
||||
}
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
unstyled: process.env.TAMAGUI_HEADLESS === "1"
|
||||
}
|
||||
});
|
||||
var H5 = (0, import_web4.styled)(Heading, {
|
||||
name: "H5",
|
||||
tag: "h5",
|
||||
variants: {
|
||||
unstyled: {
|
||||
false: {
|
||||
size: "$6"
|
||||
}
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
unstyled: process.env.TAMAGUI_HEADLESS === "1"
|
||||
}
|
||||
});
|
||||
var H6 = (0, import_web4.styled)(Heading, {
|
||||
name: "H6",
|
||||
tag: "h6",
|
||||
variants: {
|
||||
unstyled: {
|
||||
false: {
|
||||
size: "$5"
|
||||
}
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
unstyled: process.env.TAMAGUI_HEADLESS === "1"
|
||||
}
|
||||
});
|
||||
|
||||
// node_modules/@tamagui/text/dist/esm/wrapChildrenInText.mjs
|
||||
var import_react2 = __toESM(require("react"), 1);
|
||||
var import_jsx_runtime = require("react/jsx-runtime");
|
||||
function wrapChildrenInText(TextComponent, propsIn, extraProps) {
|
||||
const {
|
||||
children,
|
||||
textProps,
|
||||
size,
|
||||
noTextWrap,
|
||||
color,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
fontWeight,
|
||||
letterSpacing,
|
||||
textAlign,
|
||||
fontStyle,
|
||||
maxFontSizeMultiplier
|
||||
} = propsIn;
|
||||
if (noTextWrap || !children) return [children];
|
||||
const props = {
|
||||
...extraProps
|
||||
};
|
||||
return color && (props.color = color), fontFamily && (props.fontFamily = fontFamily), fontSize && (props.fontSize = fontSize), fontWeight && (props.fontWeight = fontWeight), letterSpacing && (props.letterSpacing = letterSpacing), textAlign && (props.textAlign = textAlign), size && (props.size = size), fontStyle && (props.fontStyle = fontStyle), maxFontSizeMultiplier && (props.maxFontSizeMultiplier = maxFontSizeMultiplier), import_react2.default.Children.toArray(children).map((child, index) => typeof child == "string" ? (
|
||||
// so "data-disable-theme" is a hack to fix themeInverse, don't ask me why
|
||||
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(TextComponent, {
|
||||
...props,
|
||||
...textProps,
|
||||
children: child
|
||||
}, index)
|
||||
) : child);
|
||||
}
|
||||
__name(wrapChildrenInText, "wrapChildrenInText");
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
H1,
|
||||
H2,
|
||||
H3,
|
||||
H4,
|
||||
H5,
|
||||
H6,
|
||||
Heading,
|
||||
Paragraph,
|
||||
SizableText,
|
||||
wrapChildrenInText
|
||||
});
|
||||
61
docs/mobile-styleguide.md
Normal file
61
docs/mobile-styleguide.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Mobile Admin UI Style Guide (Fotospiel)
|
||||
|
||||
Derived from the provided mocks (`fotospiel-tenant-app/all_events_dashboard.png`, `event_details_dashboard.png`, etc.).
|
||||
|
||||
## Typography
|
||||
- **Body**: Montserrat Regular, 13px, color `#4b5563`; line-height ~1.4.
|
||||
- **Headings**: Montserrat SemiBold/Bold, 18–22px, color `#111827`.
|
||||
- **Meta/Labels**: 11–13px, weight 500–600; use uppercase sparingly.
|
||||
- **Pills/Status/Emotions**: 11px, weight 600, compact padding.
|
||||
|
||||
## Colors
|
||||
- Primary: `#007AFF`
|
||||
- Accent: `#5AD2F4`
|
||||
- Text main: `#111827`; secondary: `#4b5563`; muted: `#6b7280`
|
||||
- Background: `#f7f8fb`; Cards: `#ffffff`; Border: `#e5e7eb`
|
||||
- Status pills: success `#ecfdf3/#047857`; warning `#fffbeb/#92400e`; muted `#f3f4f6/#374151`
|
||||
- Action tiles palette: blue `#60a5fa`, amber `#fbbf24`, purple `#a855f7`, green `#4ade80`, coral `#fb7185`, cyan `#38bdf8`
|
||||
|
||||
## Layout & Spacing
|
||||
- Screen padding: 16–20px
|
||||
- Card radius: 14–16px; pill radius: full
|
||||
- Gaps: 8–12px between items; 16–20px between sections
|
||||
- Shadows: subtle, opacity 5–10%, soft blur for cards/nav
|
||||
|
||||
## Navigation
|
||||
- Top bar: back on left, title centered, optional right action; height ~56px; bottom border `#e5e7eb`
|
||||
- Bottom nav: fixed, 4 items (Events, My Tasks, Alerts, Profile); active uses primary; inactive muted gray
|
||||
- Use press/opacity feedback
|
||||
|
||||
## CTAs & FAB
|
||||
- Primary CTA: 56px height, full-width, solid primary, radius 14–16px, weight 700–800
|
||||
- Secondary: white surface, border `#e5e7eb`, same sizing
|
||||
- Prefer a floating action button (FAB) for add/create actions (56px diameter, primary, shadow) instead of inline form buttons
|
||||
|
||||
## Cards & Lists
|
||||
- Event cards: title 18–20px Bold, date/location rows with icons, status pill, light border, soft shadow
|
||||
- KPI tiles: icon bubble, label 12–14px, value 20–22px Bold
|
||||
- Featured badge overlay on photos; likes/uploader meta
|
||||
- Prefer Tamagui list primitives where possible (`List`/`ListItem` with `YGroup`/`Separator`) for settings-style rows instead of custom rows
|
||||
|
||||
## Forms
|
||||
- Inputs: 42–48px height, radius 10–12px, border `#e5e7eb`, padding 12px, placeholder muted
|
||||
- Segmented controls: pill container; active pill primary
|
||||
- Toggles: simple checkbox/switch with clear label
|
||||
- Use FAB for submit/primary action where appropriate
|
||||
|
||||
## Icons
|
||||
- Lucide set; sizes: 16 (meta), 20 (nav), 24 (hero/tiles)
|
||||
- Active icons use primary; meta icons gray
|
||||
|
||||
## State Handling
|
||||
- Loading: skeleton cards/tiles (faded card shapes)
|
||||
- Empty: centered card with icon + short copy + CTA/FAB
|
||||
- Errors: inline card with red text
|
||||
|
||||
## Branding/Preview
|
||||
- Preview frames: light border, soft background, show primary/accent swatches and selected fonts (Montserrat)
|
||||
|
||||
## Interaction
|
||||
- Tap targets ≥44px; press opacity feedback
|
||||
- Keep forms low-friction; placeholders clear
|
||||
5000
package-lock.json
generated
5000
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -27,6 +27,7 @@
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/fabric": "^5.3.9",
|
||||
"@types/node": "^22.13.5",
|
||||
"baseline-browser-mapping": "^2.9.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
@@ -64,6 +65,14 @@
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@stripe/stripe-js": "^8.0.0",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tamagui/button": "^1.139.2",
|
||||
"@tamagui/config": "^1.139.2",
|
||||
"@tamagui/group": "^1.139.2",
|
||||
"@tamagui/list-item": "^1.139.2",
|
||||
"@tamagui/stacks": "^1.139.2",
|
||||
"@tamagui/text": "^1.139.2",
|
||||
"@tamagui/themes": "^1.139.2",
|
||||
"@tamagui/vite-plugin": "^1.139.2",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@types/react": "^19.0.3",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
@@ -99,5 +108,8 @@
|
||||
"@rollup/rollup-linux-x64-gnu": "4.9.5",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "^4.0.1",
|
||||
"lightningcss-linux-x64-gnu": "^1.29.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"react-native-web": "^0.19.12"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,16 +114,16 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--guest-primary: #f43f5e;
|
||||
--guest-secondary: #fb7185;
|
||||
--guest-background: #ffffff;
|
||||
--guest-primary: #007aff;
|
||||
--guest-secondary: #5ad2f4;
|
||||
--guest-background: #f7f8fb;
|
||||
--guest-surface: #ffffff;
|
||||
--guest-radius: 12px;
|
||||
--guest-radius: 14px;
|
||||
--guest-button-style: filled;
|
||||
--guest-link: #fb7185;
|
||||
--guest-link: #007aff;
|
||||
--guest-font-family: 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--guest-body-font: 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--guest-heading-font: 'Playfair Display', serif;
|
||||
--guest-heading-font: 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--guest-serif-font: 'Lora', serif;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import deSettings from './locales/de/settings.json';
|
||||
import enSettings from './locales/en/settings.json';
|
||||
import deAuth from './locales/de/auth.json';
|
||||
import enAuth from './locales/en/auth.json';
|
||||
import deMobile from './locales/de/mobile.json';
|
||||
import enMobile from './locales/en/mobile.json';
|
||||
|
||||
const DEFAULT_NAMESPACE = 'common';
|
||||
|
||||
@@ -25,6 +27,7 @@ const resources = {
|
||||
management: deManagement,
|
||||
settings: deSettings,
|
||||
auth: deAuth,
|
||||
mobile: deMobile,
|
||||
},
|
||||
en: {
|
||||
common: enCommon,
|
||||
@@ -33,6 +36,7 @@ const resources = {
|
||||
management: enManagement,
|
||||
settings: enSettings,
|
||||
auth: enAuth,
|
||||
mobile: enMobile,
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
14
resources/js/admin/i18n/locales/de/mobile.json
Normal file
14
resources/js/admin/i18n/locales/de/mobile.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"nav": {
|
||||
"dashboard": "Übersicht",
|
||||
"events": "Events",
|
||||
"tasks": "Aufgaben",
|
||||
"alerts": "Alerts",
|
||||
"profile": "Profil"
|
||||
},
|
||||
"actions": {
|
||||
"back": "Zurück",
|
||||
"close": "Schließen",
|
||||
"refresh": "Aktualisieren"
|
||||
}
|
||||
}
|
||||
14
resources/js/admin/i18n/locales/en/mobile.json
Normal file
14
resources/js/admin/i18n/locales/en/mobile.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"events": "Events",
|
||||
"tasks": "Tasks",
|
||||
"alerts": "Alerts",
|
||||
"profile": "Profile"
|
||||
},
|
||||
"actions": {
|
||||
"back": "Back",
|
||||
"close": "Close",
|
||||
"refresh": "Refresh"
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { TamaguiProvider } from '@tamagui/core';
|
||||
import '@tamagui/core/reset.css';
|
||||
import tamaguiConfig from '../../../tamagui.config';
|
||||
import { AuthProvider } from './auth/context';
|
||||
import { router } from './router';
|
||||
import '../../css/app.css';
|
||||
@@ -43,32 +46,36 @@ if ('serviceWorker' in navigator) {
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<ConsentProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<EventProvider>
|
||||
<OnboardingProgressProvider>
|
||||
<MatomoTracker config={(window as any).__MATOMO_ADMIN__} />
|
||||
<Suspense
|
||||
fallback={(
|
||||
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
|
||||
Oberfläche wird geladen …
|
||||
<TamaguiProvider config={tamaguiConfig} defaultTheme="light">
|
||||
<ConsentProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<EventProvider>
|
||||
<OnboardingProgressProvider>
|
||||
<MatomoTracker config={(window as any).__MATOMO_ADMIN__} />
|
||||
<Suspense
|
||||
fallback={(
|
||||
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
|
||||
Oberfläche wird geladen …
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="font-[Montserrat] text-[13px] font-normal leading-[1.5] text-slate-700">
|
||||
<RouterProvider router={router} />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<RouterProvider router={router} />
|
||||
</Suspense>
|
||||
</OnboardingProgressProvider>
|
||||
</EventProvider>
|
||||
</AuthProvider>
|
||||
<CookieBanner />
|
||||
<Toaster position="top-right" toastOptions={{ duration: 4000 }} />
|
||||
{enableDevSwitcher ? (
|
||||
<Suspense fallback={null}>
|
||||
<DevTenantSwitcher />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</QueryClientProvider>
|
||||
</ConsentProvider>
|
||||
</Suspense>
|
||||
</OnboardingProgressProvider>
|
||||
</EventProvider>
|
||||
</AuthProvider>
|
||||
<CookieBanner />
|
||||
<Toaster position="top-right" toastOptions={{ duration: 4000 }} />
|
||||
{enableDevSwitcher ? (
|
||||
<Suspense fallback={null}>
|
||||
<DevTenantSwitcher />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</QueryClientProvider>
|
||||
</ConsentProvider>
|
||||
</TamaguiProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
197
resources/js/admin/mobile/AlertsPage.tsx
Normal file
197
resources/js/admin/mobile/AlertsPage.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Bell, RefreshCcw } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { MobileScaffold } from './components/Scaffold';
|
||||
import { MobileCard, PillBadge } from './components/Primitives';
|
||||
import { BottomNav } from './components/BottomNav';
|
||||
import { GuestNotificationSummary, listGuestNotifications } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useMobileNav } from './hooks/useMobileNav';
|
||||
import { MobileSheet } from './components/Sheet';
|
||||
import { getEvents, TenantEvent } from '../api';
|
||||
|
||||
type AlertItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
time: string;
|
||||
tone: 'info' | 'warning';
|
||||
};
|
||||
|
||||
async function loadNotifications(slug?: string): Promise<AlertItem[]> {
|
||||
try {
|
||||
const result = slug ? await listGuestNotifications(slug) : [];
|
||||
return (result ?? []).map((item: GuestNotificationSummary) => ({
|
||||
id: String(item.id),
|
||||
title: item.title || 'Alert',
|
||||
body: item.body ?? '',
|
||||
time: item.created_at ?? '',
|
||||
tone: item.type === 'support_tip' ? 'warning' : 'info',
|
||||
}));
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export default function MobileAlertsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
const search = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '');
|
||||
const slug = search.get('event') ?? undefined;
|
||||
const [alerts, setAlerts] = React.useState<AlertItem[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const { go } = useMobileNav(slug ?? null);
|
||||
const [events, setEvents] = React.useState<TenantEvent[]>([]);
|
||||
const [showEventPicker, setShowEventPicker] = React.useState(false);
|
||||
|
||||
const reload = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await loadNotifications(slug ?? undefined);
|
||||
setAlerts(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
const message = getApiErrorMessage(err, t('events.errors.loadFailed', 'Alerts konnten nicht geladen werden.'));
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [slug, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const list = await getEvents();
|
||||
setEvents(list);
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MobileScaffold
|
||||
title={t('alerts.title', 'Alerts')}
|
||||
onBack={() => navigate(-1)}
|
||||
rightSlot={
|
||||
<Pressable onPress={() => reload()}>
|
||||
<RefreshCcw size={18} color="#0f172a" />
|
||||
</Pressable>
|
||||
}
|
||||
footer={
|
||||
<BottomNav active="alerts" onNavigate={go} />
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color="#b91c1c">
|
||||
{error}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<YStack space="$2">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<MobileCard key={`al-${idx}`} height={70} opacity={0.6} />
|
||||
))}
|
||||
</YStack>
|
||||
) : alerts.length === 0 ? (
|
||||
<MobileCard alignItems="center" justifyContent="center" space="$2">
|
||||
<Bell size={24} color="#9ca3af" />
|
||||
<Text fontSize="$sm" color="#4b5563">
|
||||
{t('alerts.empty', 'Keine Alerts vorhanden.')}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : (
|
||||
<YStack space="$2">
|
||||
{events.length ? (
|
||||
<Pressable onPress={() => setShowEventPicker(true)}>
|
||||
<Text fontSize="$sm" color="#007AFF" fontWeight="700">
|
||||
{t('alerts.filterByEvent', 'Filter by event')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{alerts.map((item) => (
|
||||
<MobileCard key={item.id} space="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<XStack
|
||||
width={36}
|
||||
height={36}
|
||||
borderRadius={12}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor={item.tone === 'warning' ? '#fef3c7' : '#e0f2fe'}
|
||||
>
|
||||
<Bell size={18} color={item.tone === 'warning' ? '#92400e' : '#2563eb'} />
|
||||
</XStack>
|
||||
<YStack space="$0.5" flex={1}>
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color="#4b5563">
|
||||
{item.body}
|
||||
</Text>
|
||||
</YStack>
|
||||
<PillBadge tone={item.tone === 'warning' ? 'warning' : 'muted'}>{item.time}</PillBadge>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
))}
|
||||
</YStack>
|
||||
)}
|
||||
|
||||
<MobileSheet
|
||||
open={showEventPicker}
|
||||
onClose={() => setShowEventPicker(false)}
|
||||
title={t('alerts.filterByEvent', 'Filter by event')}
|
||||
footer={null}
|
||||
>
|
||||
<YStack space="$2">
|
||||
{events.length === 0 ? (
|
||||
<Text fontSize="$sm" color="#4b5563">
|
||||
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
|
||||
</Text>
|
||||
) : (
|
||||
events.map((ev) => (
|
||||
<Pressable
|
||||
key={ev.slug}
|
||||
onPress={() => {
|
||||
setShowEventPicker(false);
|
||||
if (ev.slug) {
|
||||
navigate(`/admin/mobile/alerts?event=${ev.slug}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
|
||||
<YStack>
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
{ev.name}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
{ev.slug}
|
||||
</Text>
|
||||
</YStack>
|
||||
<PillBadge tone="muted">{ev.status ?? '—'}</PillBadge>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
))
|
||||
)}
|
||||
</YStack>
|
||||
</MobileSheet>
|
||||
</MobileScaffold>
|
||||
);
|
||||
}
|
||||
378
resources/js/admin/mobile/BrandingPage.tsx
Normal file
378
resources/js/admin/mobile/BrandingPage.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Image as ImageIcon, RefreshCcw, Save } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { MobileScaffold } from './components/Scaffold';
|
||||
import { MobileCard, CTAButton } from './components/Primitives';
|
||||
import { BottomNav } from './components/BottomNav';
|
||||
import { TenantEvent, getEvent, updateEvent, getTenantFonts, TenantFont } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { useMobileNav } from './hooks/useMobileNav';
|
||||
import { MobileSheet } from './components/Sheet';
|
||||
|
||||
type BrandingForm = {
|
||||
primary: string;
|
||||
accent: string;
|
||||
headingFont: string;
|
||||
bodyFont: string;
|
||||
};
|
||||
|
||||
export default function MobileBrandingPage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const slug = slugParam ?? null;
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||
const [form, setForm] = React.useState<BrandingForm>({
|
||||
primary: '#007AFF',
|
||||
accent: '#5AD2F4',
|
||||
headingFont: '',
|
||||
bodyFont: '',
|
||||
});
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const { go } = useMobileNav(slug);
|
||||
const [showFontsSheet, setShowFontsSheet] = React.useState(false);
|
||||
const [fonts, setFonts] = React.useState<TenantFont[]>([]);
|
||||
const [fontsLoading, setFontsLoading] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!slug) return;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getEvent(slug);
|
||||
setEvent(data);
|
||||
setForm(extractBranding(data));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Branding konnte nicht geladen werden.')));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [slug, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
setFontsLoading(true);
|
||||
try {
|
||||
const data = await getTenantFonts();
|
||||
setFonts(data ?? []);
|
||||
} catch {
|
||||
// non-fatal
|
||||
} finally {
|
||||
setFontsLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const previewTitle = event ? renderName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
|
||||
|
||||
async function handleSave() {
|
||||
if (!event?.slug) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const settings = { ...(event.settings ?? {}) };
|
||||
settings.branding = {
|
||||
...(typeof settings.branding === 'object' ? (settings.branding as Record<string, unknown>) : {}),
|
||||
primary_color: form.primary,
|
||||
accent_color: form.accent,
|
||||
heading_font: form.headingFont,
|
||||
body_font: form.bodyFont,
|
||||
};
|
||||
const updated = await updateEvent(event.slug, { settings });
|
||||
setEvent(updated);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Branding konnte nicht gespeichert werden.')));
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
if (event) {
|
||||
setForm(extractBranding(event));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileScaffold
|
||||
title={t('events.branding.title', 'Branding & Customization')}
|
||||
onBack={() => navigate(-1)}
|
||||
rightSlot={
|
||||
<Pressable disabled={saving} onPress={() => handleSave()}>
|
||||
<Save size={18} color="#007AFF" />
|
||||
</Pressable>
|
||||
}
|
||||
footer={
|
||||
<BottomNav active="events" onNavigate={go} />
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color="#b91c1c">
|
||||
{error}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
{t('events.branding.previewTitle', 'Guest App Preview')}
|
||||
</Text>
|
||||
<YStack borderRadius={16} borderWidth={1} borderColor="#e5e7eb" backgroundColor="#f8fafc" padding="$3" space="$2" alignItems="center">
|
||||
<YStack width="100%" borderRadius={12} backgroundColor="white" borderWidth={1} borderColor="#e5e7eb" overflow="hidden">
|
||||
<YStack backgroundColor={form.primary} height={64} />
|
||||
<YStack padding="$3" space="$1.5">
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
{previewTitle}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color="#4b5563">
|
||||
{t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')}
|
||||
</Text>
|
||||
<XStack space="$2" marginTop="$1">
|
||||
<ColorSwatch color={form.primary} label={t('events.branding.primary', 'Primary')} />
|
||||
<ColorSwatch color={form.accent} label={t('events.branding.accent', 'Accent')} />
|
||||
</XStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
{t('events.branding.colors', 'Colors')}
|
||||
</Text>
|
||||
<ColorField
|
||||
label={t('events.branding.primary', 'Primary Color')}
|
||||
value={form.primary}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, primary: value }))}
|
||||
/>
|
||||
<ColorField
|
||||
label={t('events.branding.accent', 'Accent Color')}
|
||||
value={form.accent}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, accent: value }))}
|
||||
/>
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
{t('events.branding.fonts', 'Fonts')}
|
||||
</Text>
|
||||
<InputField
|
||||
label={t('events.branding.headingFont', 'Headline Font')}
|
||||
value={form.headingFont}
|
||||
placeholder="SF Pro Display"
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, headingFont: value }))}
|
||||
/>
|
||||
<InputField
|
||||
label={t('events.branding.bodyFont', 'Body Font')}
|
||||
value={form.bodyFont}
|
||||
placeholder="SF Pro Text"
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, bodyFont: value }))}
|
||||
/>
|
||||
<Pressable onPress={() => setShowFontsSheet(true)}>
|
||||
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
|
||||
<Text fontSize="$sm" color="#007AFF" fontWeight="700">
|
||||
{t('events.branding.chooseFont', 'Choose from installed fonts')}
|
||||
</Text>
|
||||
<Save size={16} color="#007AFF" />
|
||||
</XStack>
|
||||
</Pressable>
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
{t('events.branding.logo', 'Logo')}
|
||||
</Text>
|
||||
<YStack
|
||||
borderRadius={14}
|
||||
borderWidth={1}
|
||||
borderColor="#e5e7eb"
|
||||
backgroundColor="#f8fafc"
|
||||
padding="$3"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
space="$2"
|
||||
>
|
||||
<ImageIcon size={28} color="#94a3b8" />
|
||||
<Text fontSize="$sm" color="#4b5563">
|
||||
{t('events.branding.logoHint', 'Logo Upload folgt – nutze Farben/Schriften.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
|
||||
<YStack space="$2">
|
||||
<CTAButton label={saving ? t('events.branding.saving', 'Saving...') : t('events.branding.save', 'Save Branding')} onPress={() => handleSave()} />
|
||||
<Pressable disabled={loading || saving} onPress={handleReset}>
|
||||
<XStack
|
||||
height={52}
|
||||
borderRadius={14}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor="white"
|
||||
borderWidth={1}
|
||||
borderColor="#e5e7eb"
|
||||
space="$2"
|
||||
>
|
||||
<RefreshCcw size={16} color="#111827" />
|
||||
<Text fontSize="$sm" color="#111827" fontWeight="700">
|
||||
{t('events.branding.reset', 'Reset to Defaults')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
</YStack>
|
||||
|
||||
<MobileSheet
|
||||
open={showFontsSheet}
|
||||
onClose={() => setShowFontsSheet(false)}
|
||||
title={t('events.branding.fontPicker', 'Select font')}
|
||||
footer={null}
|
||||
bottomOffsetPx={120}
|
||||
>
|
||||
<YStack space="$2">
|
||||
{fontsLoading ? (
|
||||
Array.from({ length: 4 }).map((_, idx) => <MobileCard key={`font-sk-${idx}`} height={48} opacity={0.6} />)
|
||||
) : fonts.length === 0 ? (
|
||||
<Text fontSize="$sm" color="#4b5563">
|
||||
{t('events.branding.noFonts', 'Keine Schriftarten gefunden.')}
|
||||
</Text>
|
||||
) : (
|
||||
fonts.map((font) => (
|
||||
<Pressable
|
||||
key={font.family}
|
||||
onPress={() => {
|
||||
setForm((prev) => ({ ...prev, headingFont: font.family, bodyFont: font.family }));
|
||||
setShowFontsSheet(false);
|
||||
}}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
|
||||
<YStack>
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{font.family}
|
||||
</Text>
|
||||
{font.variants?.length ? (
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
{font.variants.map((v) => v.style ?? v.weight ?? '').filter(Boolean).join(', ')}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
{form.headingFont === font.family || form.bodyFont === font.family ? (
|
||||
<Text fontSize="$xs" color="#007AFF">
|
||||
{t('common.active', 'Active')}
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
</Pressable>
|
||||
))
|
||||
)}
|
||||
</YStack>
|
||||
</MobileSheet>
|
||||
</MobileScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
function extractBranding(event: TenantEvent): BrandingForm {
|
||||
const source = (event.settings as Record<string, unknown>) ?? {};
|
||||
const branding = (source.branding as Record<string, unknown>) ?? source;
|
||||
const readColor = (key: string, fallback: string) => {
|
||||
const value = branding[key];
|
||||
return typeof value === 'string' && value.startsWith('#') ? value : fallback;
|
||||
};
|
||||
const readText = (key: string) => {
|
||||
const value = branding[key];
|
||||
return typeof value === 'string' ? value : '';
|
||||
};
|
||||
return {
|
||||
primary: readColor('primary_color', '#007AFF'),
|
||||
accent: readColor('accent_color', '#5AD2F4'),
|
||||
headingFont: readText('heading_font'),
|
||||
bodyFont: readText('body_font'),
|
||||
};
|
||||
}
|
||||
|
||||
function renderName(name: TenantEvent['name']): string {
|
||||
if (typeof name === 'string') return name;
|
||||
if (name && typeof name === 'object') {
|
||||
return name.de ?? name.en ?? Object.values(name)[0] ?? '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (next: string) => void }) {
|
||||
return (
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
{label}
|
||||
</Text>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
style={{ width: 52, height: 52, borderRadius: 12, border: '1px solid #e5e7eb', background: 'white' }}
|
||||
/>
|
||||
<Text fontSize="$sm" color="#4b5563">
|
||||
{value}
|
||||
</Text>
|
||||
</XStack>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorSwatch({ color, label }: { color: string; label: string }) {
|
||||
return (
|
||||
<YStack alignItems="center" space="$1">
|
||||
<YStack width={40} height={40} borderRadius={12} borderWidth={1} borderColor="#e5e7eb" backgroundColor={color} />
|
||||
<Text fontSize="$xs" color="#4b5563">
|
||||
{label}
|
||||
</Text>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
function InputField({
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
onChange: (next: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
{label}
|
||||
</Text>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
border: '1px solid #e5e7eb',
|
||||
padding: '0 12px',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
204
resources/js/admin/mobile/DashboardPage.tsx
Normal file
204
resources/js/admin/mobile/DashboardPage.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CalendarDays, MapPin, Settings, Plus, Bell, ListTodo, Image as ImageIcon } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { MobileScaffold } from './components/Scaffold';
|
||||
import { MobileCard, PillBadge, CTAButton, KpiTile, ActionTile } from './components/Primitives';
|
||||
import { BottomNav } from './components/BottomNav';
|
||||
import { getEvents, TenantEvent } from '../api';
|
||||
import { adminPath } from '../constants';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { useMobileNav } from './hooks/useMobileNav';
|
||||
import { getEventStats, EventStats } from '../api';
|
||||
|
||||
export default function MobileDashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
const [events, setEvents] = React.useState<TenantEvent[]>([]);
|
||||
const [stats, setStats] = React.useState<Record<string, EventStats>>({});
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const { go } = useMobileNav();
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
setEvents(await getEvents());
|
||||
const fetched: Record<string, EventStats> = {};
|
||||
const list = await getEvents();
|
||||
await Promise.all(
|
||||
(list || []).map(async (ev) => {
|
||||
if (!ev.slug) return;
|
||||
try {
|
||||
fetched[ev.slug] = await getEventStats(ev.slug);
|
||||
} catch {
|
||||
// ignore per-event stat failures
|
||||
}
|
||||
})
|
||||
);
|
||||
setStats(fetched);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Events konnten nicht geladen werden.')));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<MobileScaffold
|
||||
title={t('events.list.dashboardTitle', 'All Events Dashboard')}
|
||||
onBack={() => navigate(-1)}
|
||||
rightSlot={
|
||||
<Pressable onPress={() => navigate(adminPath('/settings'))}>
|
||||
<Settings size={18} color="#0f172a" />
|
||||
</Pressable>
|
||||
}
|
||||
footer={
|
||||
<BottomNav active="events" onNavigate={go} />
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color="#b91c1c">
|
||||
{error}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
<CTAButton label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/mobile/events/new'))} />
|
||||
|
||||
{loading ? (
|
||||
<YStack space="$2">
|
||||
{Array.from({ length: 3 }).map((_, idx) => (
|
||||
<MobileCard key={`sk-${idx}`} height={90} opacity={0.6} />
|
||||
))}
|
||||
</YStack>
|
||||
) : events.length === 0 ? (
|
||||
<MobileCard alignItems="center" justifyContent="center" space="$2">
|
||||
<Text fontSize="$md" fontWeight="700" color="#111827">
|
||||
{t('events.list.empty.title', 'Noch kein Event angelegt')}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color="#4b5563" textAlign="center">
|
||||
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
|
||||
</Text>
|
||||
<CTAButton label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/mobile/events/new'))} />
|
||||
</MobileCard>
|
||||
) : (
|
||||
<YStack space="$3">
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
{t('dashboard.kpis', 'Key Performance Indicators')}
|
||||
</Text>
|
||||
<XStack space="$2" flexWrap="wrap">
|
||||
<KpiTile icon={ListTodo} label={t('events.detail.kpi.tasks', 'Tasks Completed')} value="—" />
|
||||
<KpiTile icon={Bell} label={t('events.detail.kpi.guests', 'Guests Registered')} value="—" />
|
||||
<KpiTile icon={ImageIcon} label={t('events.detail.kpi.photos', 'Images Uploaded')} value="—" />
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
{events.map((event) => (
|
||||
<MobileCard key={event.id} borderColor="#e2e8f0" space="$2">
|
||||
<XStack justifyContent="space-between" alignItems="flex-start" space="$2">
|
||||
<YStack space="$1.5">
|
||||
<Text fontSize="$lg" fontWeight="800" color="#111827">
|
||||
{renderName(event.name)}
|
||||
</Text>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<CalendarDays size={14} color="#6b7280" />
|
||||
<Text fontSize="$sm" color="#4b5563">
|
||||
{formatDate(event.event_date)}
|
||||
</Text>
|
||||
</XStack>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<MapPin size={14} color="#6b7280" />
|
||||
<Text fontSize="$sm" color="#4b5563">
|
||||
{resolveLocation(event)}
|
||||
</Text>
|
||||
</XStack>
|
||||
<PillBadge tone={resolveTone(event)}>{resolveStatus(event, t)}</PillBadge>
|
||||
</YStack>
|
||||
<Pressable onPress={() => navigate(adminPath(`/mobile/events/${event.slug}`))}>
|
||||
<Text fontSize="$xl" color="#9ca3af">
|
||||
˅
|
||||
</Text>
|
||||
</Pressable>
|
||||
</XStack>
|
||||
|
||||
<XStack marginTop="$2" space="$2" flexWrap="wrap">
|
||||
<ActionTile
|
||||
icon={ListTodo}
|
||||
label={t('events.quick.tasks', 'Tasks')}
|
||||
color="#60a5fa"
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${event.slug}/tasks`))}
|
||||
width="32%"
|
||||
/>
|
||||
<ActionTile
|
||||
icon={ImageIcon}
|
||||
label={t('events.quick.images', 'Images')}
|
||||
color="#a855f7"
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${event.slug}/photos`))}
|
||||
width="32%"
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Bell}
|
||||
label={t('alerts.title', 'Alerts')}
|
||||
color="#fbbf24"
|
||||
onPress={() => navigate(adminPath(`/mobile/alerts?event=${event.slug}`))}
|
||||
width="32%"
|
||||
/>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
))}
|
||||
</YStack>
|
||||
)}
|
||||
</MobileScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
function renderName(name: TenantEvent['name']): string {
|
||||
if (typeof name === 'string') return name;
|
||||
if (name && typeof name === 'object') {
|
||||
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Unbenanntes Event';
|
||||
}
|
||||
return 'Unbenanntes Event';
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return 'Date tbd';
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 'Date tbd';
|
||||
}
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
function resolveLocation(event: TenantEvent): string {
|
||||
const settings = (event.settings ?? {}) as Record<string, unknown>;
|
||||
const candidate =
|
||||
(settings.location as string | undefined) ??
|
||||
(settings.address as string | undefined) ??
|
||||
(settings.city as string | undefined);
|
||||
if (candidate && candidate.trim()) {
|
||||
return candidate;
|
||||
}
|
||||
return 'Location';
|
||||
}
|
||||
|
||||
function resolveStatus(event: TenantEvent, t: ReturnType<typeof useTranslation>['t']): string {
|
||||
if (event.status === 'published') return t('events.status.published', 'Upcoming');
|
||||
if (event.status === 'draft') return t('events.status.draft', 'Draft');
|
||||
return t('events.status.archived', 'Past');
|
||||
}
|
||||
|
||||
function resolveTone(event: TenantEvent): 'success' | 'warning' | 'muted' {
|
||||
if (event.status === 'published') return 'success';
|
||||
if (event.status === 'draft') return 'warning';
|
||||
return 'muted';
|
||||
}
|
||||
258
resources/js/admin/mobile/EventDetailPage.tsx
Normal file
258
resources/js/admin/mobile/EventDetailPage.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CalendarDays, MapPin, Settings, Users, Camera, Sparkles, QrCode, Image, Shield, Layout, RefreshCcw, ChevronDown } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { MobileScaffold } from './components/Scaffold';
|
||||
import { MobileCard, PillBadge, KpiTile, ActionTile } from './components/Primitives';
|
||||
import { BottomNav } from './components/BottomNav';
|
||||
import { TenantEvent, EventStats, EventToolkit, getEvent, getEventStats, getEventToolkit } from '../api';
|
||||
import { adminPath, ADMIN_EVENT_BRANDING_PATH, ADMIN_EVENT_INVITES_PATH, ADMIN_EVENT_MEMBERS_PATH, ADMIN_EVENT_PHOTOS_PATH, ADMIN_EVENT_TASKS_PATH } from '../constants';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { useMobileNav } from './hooks/useMobileNav';
|
||||
import { MobileSheet } from './components/Sheet';
|
||||
import { useEventContext } from '../context/EventContext';
|
||||
|
||||
export default function MobileEventDetailPage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const slug = slugParam ?? null;
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||
const [stats, setStats] = React.useState<EventStats | null>(null);
|
||||
const [toolkit, setToolkit] = React.useState<EventToolkit | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const { go } = useMobileNav(slug);
|
||||
const { events, activeEvent, selectEvent } = useEventContext();
|
||||
const [showEventPicker, setShowEventPicker] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!slug) return;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [eventData, statsData, toolkitData] = await Promise.all([getEvent(slug), getEventStats(slug), getEventToolkit(slug)]);
|
||||
setEvent(eventData);
|
||||
setStats(statsData);
|
||||
setToolkit(toolkitData);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [slug, t]);
|
||||
|
||||
const kpis = [
|
||||
{
|
||||
label: t('events.detail.kpi.tasks', 'Tasks Completed'),
|
||||
value: toolkit?.tasks?.summary ? `${toolkit.tasks.summary.completed}/${toolkit.tasks.summary.total}` : '—',
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
label: t('events.detail.kpi.guests', 'Guests Registered'),
|
||||
value: toolkit?.invites?.summary.total ?? event?.active_invites_count ?? '—',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
label: t('events.detail.kpi.photos', 'Images Uploaded'),
|
||||
value: stats?.uploads_total ?? event?.photo_count ?? '—',
|
||||
icon: Camera,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<MobileScaffold
|
||||
title={t('events.detail.title', 'Event Details Dashboard')}
|
||||
onBack={() => navigate(adminPath('/mobile/events'))}
|
||||
rightSlot={
|
||||
<XStack space="$3" alignItems="center">
|
||||
<Pressable onPress={() => setShowEventPicker(true)}>
|
||||
<XStack alignItems="center" space="$1.5">
|
||||
<Text fontSize="$sm" color="#007AFF" fontWeight="600">
|
||||
{activeEvent?.name ?? t('events.detail.pickEvent', 'Event wählen')}
|
||||
</Text>
|
||||
<ChevronDown size={14} color="#007AFF" />
|
||||
</XStack>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => navigate(adminPath('/settings'))}>
|
||||
<Settings size={18} color="#0f172a" />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => navigate(0)}>
|
||||
<RefreshCcw size={18} color="#0f172a" />
|
||||
</Pressable>
|
||||
</XStack>
|
||||
}
|
||||
footer={
|
||||
<BottomNav active="events" onNavigate={go} />
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color="#b91c1c">
|
||||
{error}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$lg" fontWeight="800" color="#111827">
|
||||
{event ? renderName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event')}
|
||||
</Text>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<CalendarDays size={16} color="#6b7280" />
|
||||
<Text fontSize="$sm" color="#4b5563">
|
||||
{formatDate(event?.event_date)}
|
||||
</Text>
|
||||
<MapPin size={16} color="#6b7280" />
|
||||
<Text fontSize="$sm" color="#4b5563">
|
||||
{resolveLocation(event)}
|
||||
</Text>
|
||||
</XStack>
|
||||
<PillBadge tone={event?.status === 'published' ? 'success' : 'warning'}>
|
||||
{event?.status === 'published' ? t('events.status.published', 'Live') : t('events.status.draft', 'Draft')}
|
||||
</PillBadge>
|
||||
</MobileCard>
|
||||
|
||||
<YStack space="$2">
|
||||
{loading ? (
|
||||
<XStack space="$2" flexWrap="wrap">
|
||||
{Array.from({ length: 3 }).map((_, idx) => (
|
||||
<MobileCard key={`kpi-${idx}`} height={90} width="32%" />
|
||||
))}
|
||||
</XStack>
|
||||
) : (
|
||||
<XStack space="$2" flexWrap="wrap">
|
||||
{kpis.map((kpi) => (
|
||||
<KpiTile key={kpi.label} icon={kpi.icon} label={kpi.label} value={kpi.value} />
|
||||
))}
|
||||
</XStack>
|
||||
)}
|
||||
</YStack>
|
||||
|
||||
<MobileSheet
|
||||
open={showEventPicker}
|
||||
onClose={() => setShowEventPicker(false)}
|
||||
title={t('events.detail.pickEvent', 'Event wählen')}
|
||||
footer={null}
|
||||
bottomOffsetPx={120}
|
||||
>
|
||||
<YStack space="$2">
|
||||
{events.length === 0 ? (
|
||||
<Text fontSize={12.5} color="#4b5563">
|
||||
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
|
||||
</Text>
|
||||
) : (
|
||||
events.map((ev) => (
|
||||
<Pressable
|
||||
key={ev.slug}
|
||||
onPress={() => {
|
||||
selectEvent(ev.slug ?? null);
|
||||
setShowEventPicker(false);
|
||||
navigate(adminPath(`/mobile/events/${ev.slug}`));
|
||||
}}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
|
||||
<YStack space="$1">
|
||||
<Text fontSize={13} fontWeight="700" color="#111827">
|
||||
{renderName(ev.name)}
|
||||
</Text>
|
||||
<XStack alignItems="center" space="$1.5">
|
||||
<CalendarDays size={14} color="#6b7280" />
|
||||
<Text fontSize={12} color="#4b5563">
|
||||
{formatDate(ev.event_date)}
|
||||
</Text>
|
||||
</XStack>
|
||||
</YStack>
|
||||
<PillBadge tone={ev.slug === activeEvent?.slug ? 'success' : 'muted'}>
|
||||
{ev.slug === activeEvent?.slug ? t('events.detail.active', 'Aktiv') : t('events.actions.open', 'Öffnen')}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
))
|
||||
)}
|
||||
</YStack>
|
||||
</MobileSheet>
|
||||
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
{t('events.detail.managementTitle', 'Event Management')}
|
||||
</Text>
|
||||
<XStack flexWrap="wrap" space="$2">
|
||||
<ActionTile
|
||||
icon={Sparkles}
|
||||
label={t('events.quick.tasks', 'Tasks & Checklists')}
|
||||
color="#60a5fa"
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/tasks`))}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={QrCode}
|
||||
label={t('events.quick.qr', 'QR Code Layouts')}
|
||||
color="#fbbf24"
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/qr`))}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Image}
|
||||
label={t('events.quick.images', 'Image Management')}
|
||||
color="#a855f7"
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photos`))}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Users}
|
||||
label={t('events.quick.guests', 'Guest Management')}
|
||||
color="#4ade80"
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/members`))}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Layout}
|
||||
label={t('events.quick.branding', 'Branding & Theme')}
|
||||
color="#fb7185"
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/branding`))}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Shield}
|
||||
label={t('events.quick.moderation', 'Photo Moderation')}
|
||||
color="#38bdf8"
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photos`))}
|
||||
/>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</MobileScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
function renderName(name: TenantEvent['name']): string {
|
||||
if (typeof name === 'string') return name;
|
||||
if (name && typeof name === 'object') {
|
||||
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Unbenanntes Event';
|
||||
}
|
||||
return 'Unbenanntes Event';
|
||||
}
|
||||
|
||||
function formatDate(iso?: string | null): string {
|
||||
if (!iso) return 'Date tbd';
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return 'Date tbd';
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
function resolveLocation(event: TenantEvent | null): string {
|
||||
if (!event) return 'Location';
|
||||
const settings = (event.settings ?? {}) as Record<string, unknown>;
|
||||
const candidate =
|
||||
(settings.location as string | undefined) ??
|
||||
(settings.address as string | undefined) ??
|
||||
(settings.city as string | undefined);
|
||||
if (candidate && candidate.trim()) {
|
||||
return candidate;
|
||||
}
|
||||
return 'Location';
|
||||
}
|
||||
266
resources/js/admin/mobile/EventFormPage.tsx
Normal file
266
resources/js/admin/mobile/EventFormPage.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CalendarDays, ChevronDown, MapPin } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { MobileScaffold } from './components/Scaffold';
|
||||
import { MobileCard, CTAButton } from './components/Primitives';
|
||||
import { BottomNav } from './components/BottomNav';
|
||||
import { createEvent, getEvent, updateEvent, TenantEvent } from '../api';
|
||||
import { adminPath } from '../constants';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { useMobileNav } from './hooks/useMobileNav';
|
||||
|
||||
type FormState = {
|
||||
name: string;
|
||||
date: string;
|
||||
eventType: string;
|
||||
description: string;
|
||||
location: string;
|
||||
enableBranding: boolean;
|
||||
};
|
||||
|
||||
const EVENT_TYPES = ['Wedding', 'Corporate', 'Party', 'Other'];
|
||||
|
||||
export default function MobileEventFormPage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const slug = slugParam ?? null;
|
||||
const isEdit = Boolean(slug);
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const [form, setForm] = React.useState<FormState>({
|
||||
name: '',
|
||||
date: '',
|
||||
eventType: EVENT_TYPES[0],
|
||||
description: '',
|
||||
location: '',
|
||||
enableBranding: false,
|
||||
});
|
||||
const [loading, setLoading] = React.useState(isEdit);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const { go } = useMobileNav(slug);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!slug) return;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getEvent(slug);
|
||||
setForm({
|
||||
name: renderName(data.name),
|
||||
date: data.event_date ?? '',
|
||||
eventType: data.event_type?.name ?? EVENT_TYPES[0],
|
||||
description: typeof data.description === 'string' ? data.description : '',
|
||||
location: resolveLocation(data),
|
||||
enableBranding: Boolean((data.settings as Record<string, unknown>)?.branding_allowed ?? true),
|
||||
});
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [slug, t, isEdit]);
|
||||
|
||||
async function handleSubmit() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (isEdit && slug) {
|
||||
await updateEvent(slug, {
|
||||
name: form.name,
|
||||
event_date: form.date || undefined,
|
||||
settings: { branding_allowed: form.enableBranding, location: form.location },
|
||||
});
|
||||
navigate(adminPath(`/mobile/events/${slug}`));
|
||||
} else {
|
||||
const payload = {
|
||||
name: form.name || 'Event',
|
||||
slug: `${Date.now()}`,
|
||||
event_type_id: 1,
|
||||
event_date: form.date || undefined,
|
||||
status: 'draft' as const,
|
||||
settings: { branding_allowed: form.enableBranding, location: form.location },
|
||||
};
|
||||
const { event } = await createEvent(payload as any);
|
||||
navigate(adminPath(`/mobile/events/${event.slug}`));
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Event konnte nicht gespeichert werden.')));
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileScaffold
|
||||
title={isEdit ? t('events.form.editTitle', 'Edit Event') : t('events.form.createTitle', 'Create New Event')}
|
||||
onBack={() => navigate(-1)}
|
||||
footer={
|
||||
<BottomNav active="events" onNavigate={go} />
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color="#b91c1c">
|
||||
{error}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
<MobileCard space="$3">
|
||||
<Field label={t('events.form.name', 'Event Name')}>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="e.g., Smith Wedding"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t('events.form.date', 'Date & Time')}>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={form.date}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, date: e.target.value }))}
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<CalendarDays size={16} color="#9ca3af" />
|
||||
</XStack>
|
||||
</Field>
|
||||
|
||||
<Field label={t('events.form.type', 'Event Type')}>
|
||||
<XStack space="$1" flexWrap="wrap">
|
||||
{EVENT_TYPES.map((type) => {
|
||||
const active = form.eventType === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setForm((prev) => ({ ...prev, eventType: type }))}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${active ? '#007AFF' : '#e5e7eb'}`,
|
||||
background: active ? '#e8f1ff' : 'white',
|
||||
color: active ? '#0f172a' : '#111827',
|
||||
fontWeight: 700,
|
||||
minWidth: 90,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{type}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
</Field>
|
||||
|
||||
<Field label={t('events.form.description', 'Optional Details')}>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
|
||||
placeholder={t('events.form.descriptionPlaceholder', 'Description')}
|
||||
style={{ ...inputStyle, minHeight: 96 }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t('events.form.location', 'Location')}>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<input
|
||||
type="text"
|
||||
value={form.location}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, location: e.target.value }))}
|
||||
placeholder={t('events.form.locationPlaceholder', 'Location')}
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<MapPin size={16} color="#9ca3af" />
|
||||
</XStack>
|
||||
</Field>
|
||||
|
||||
<Field label={t('events.form.enableBranding', 'Enable Branding & Moderation')}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.enableBranding}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, enableBranding: e.target.checked }))}
|
||||
/>
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{form.enableBranding ? t('common.enabled', 'Enabled') : t('common.disabled', 'Disabled')}
|
||||
</Text>
|
||||
</label>
|
||||
</Field>
|
||||
</MobileCard>
|
||||
|
||||
<YStack space="$2">
|
||||
{!isEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
style={{
|
||||
...inputStyle,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
border: '1px solid #e5e7eb',
|
||||
background: '#f1f5f9',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{t('events.form.saveDraft', 'Save as Draft')}
|
||||
</button>
|
||||
) : null}
|
||||
<CTAButton label={saving ? t('events.form.saving', 'Saving...') : isEdit ? t('events.form.update', 'Update Event') : t('events.form.create', 'Create Event')} onPress={() => handleSubmit()} />
|
||||
</YStack>
|
||||
</MobileScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<YStack space="$1.5">
|
||||
<Text fontSize="$sm" fontWeight="800" color="#111827">
|
||||
{label}
|
||||
</Text>
|
||||
{children}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: 44,
|
||||
borderRadius: 10,
|
||||
border: '1px solid #e5e7eb',
|
||||
padding: '0 12px',
|
||||
fontSize: 14,
|
||||
background: 'white',
|
||||
};
|
||||
|
||||
function renderName(name: TenantEvent['name']): string {
|
||||
if (typeof name === 'string') return name;
|
||||
if (name && typeof name === 'object') {
|
||||
return name.de ?? name.en ?? Object.values(name)[0] ?? '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function resolveLocation(event: TenantEvent): string {
|
||||
const settings = (event.settings ?? {}) as Record<string, unknown>;
|
||||
const candidate =
|
||||
(settings.location as string | undefined) ??
|
||||
(settings.address as string | undefined) ??
|
||||
(settings.city as string | undefined);
|
||||
if (candidate && candidate.trim()) return candidate;
|
||||
return '';
|
||||
}
|
||||
315
resources/js/admin/mobile/EventMembersPage.tsx
Normal file
315
resources/js/admin/mobile/EventMembersPage.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UserPlus, Trash2, Copy, RefreshCcw } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { MobileScaffold } from './components/Scaffold';
|
||||
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
||||
import { BottomNav } from './components/BottomNav';
|
||||
import { EventMember, getEventMembers, inviteEventMember, removeEventMember } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useMobileNav } from './hooks/useMobileNav';
|
||||
import { MobileSheet } from './components/Sheet';
|
||||
|
||||
export default function MobileEventMembersPage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const slug = slugParam ?? null;
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const [members, setMembers] = React.useState<EventMember[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [invite, setInvite] = React.useState({ name: '', email: '', role: 'member' as EventMember['role'] });
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [inviteLink, setInviteLink] = React.useState<string | null>(null);
|
||||
const { go } = useMobileNav(slug);
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [confirmRemove, setConfirmRemove] = React.useState<EventMember | null>(null);
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [confirmRemove, setConfirmRemove] = React.useState<EventMember | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!slug) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getEventMembers(slug, 1);
|
||||
setMembers(result.data);
|
||||
if (result.data.length) {
|
||||
const pending = result.data.find((m) => m.status === 'pending' && m.permissions?.includes('invite_link'));
|
||||
if (pending?.email) {
|
||||
setInviteLink(`mailto:${pending.email}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Mitglieder konnten nicht geladen werden.')));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [slug, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function handleInvite() {
|
||||
if (!slug || !invite.email.trim()) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const member = await inviteEventMember(slug, {
|
||||
email: invite.email.trim(),
|
||||
name: invite.name.trim() || undefined,
|
||||
role: invite.role,
|
||||
});
|
||||
setMembers((prev) => [member, ...prev]);
|
||||
setInvite({ name: '', email: '', role: 'member' });
|
||||
toast.success(t('events.members.inviteSuccess', 'Einladung gesendet'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Einladung fehlgeschlagen.')));
|
||||
toast.error(t('events.members.inviteFailed', 'Einladung fehlgeschlagen.'));
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(member: EventMember) {
|
||||
if (!slug) return;
|
||||
try {
|
||||
await removeEventMember(slug, member.id);
|
||||
setMembers((prev) => prev.filter((m) => m.id !== member.id));
|
||||
toast.success(t('events.members.removeSuccess', 'Mitglied entfernt'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Mitglied konnte nicht entfernt werden.')));
|
||||
toast.error(t('events.members.removeFailed', 'Mitglied konnte nicht entfernt werden.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileScaffold
|
||||
title={t('events.members.title', 'Guest Management')}
|
||||
onBack={() => navigate(-1)}
|
||||
rightSlot={
|
||||
<Pressable onPress={() => load()}>
|
||||
<RefreshCcw size={18} color="#0f172a" />
|
||||
</Pressable>
|
||||
}
|
||||
footer={
|
||||
<BottomNav active="events" onNavigate={go} />
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color="#b91c1c">
|
||||
{error}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
{t('events.members.inviteTitle', 'Invite Member')}
|
||||
</Text>
|
||||
<YStack space="$2">
|
||||
<Field label={t('events.members.name', 'Name')}>
|
||||
<input
|
||||
type="text"
|
||||
value={invite.name}
|
||||
onChange={(e) => setInvite((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="Alex Example"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('events.members.email', 'Email')}>
|
||||
<input
|
||||
type="email"
|
||||
value={invite.email}
|
||||
onChange={(e) => setInvite((prev) => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="alex@example.com"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('events.members.role', 'Role')}>
|
||||
<select
|
||||
value={invite.role}
|
||||
onChange={(e) => setInvite((prev) => ({ ...prev, role: e.target.value as EventMember['role'] }))}
|
||||
style={{ ...inputStyle, height: 44 }}
|
||||
>
|
||||
<option value="member">{t('events.members.roleMember', 'Member')}</option>
|
||||
<option value="tenant_admin">{t('events.members.roleAdmin', 'Admin')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<CTAButton label={saving ? t('common.saving', 'Saving...') : t('events.members.invite', 'Send Invite')} onPress={() => handleInvite()} />
|
||||
{saving ? (
|
||||
<Text fontSize="$xs" color="#4b5563">
|
||||
{t('common.processing', 'Processing...')}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('events.members.search', 'Search members')}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 38,
|
||||
borderRadius: 10,
|
||||
border: '1px solid #e5e7eb',
|
||||
padding: '0 12px',
|
||||
fontSize: 13,
|
||||
background: 'white',
|
||||
}}
|
||||
/>
|
||||
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
{t('events.members.listTitle', 'Team & Guests')}
|
||||
</Text>
|
||||
{inviteLink ? (
|
||||
<Pressable
|
||||
onPress={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteLink);
|
||||
toast.success(t('events.members.copyInvite', 'Einladungslink kopiert'));
|
||||
} catch {
|
||||
toast.error(t('events.members.copyInviteFailed', 'Kopieren nicht möglich'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<XStack alignItems="center" space="$2" marginBottom="$2">
|
||||
<Copy size={16} color="#007AFF" />
|
||||
<Text fontSize="$sm" color="#007AFF">
|
||||
{t('events.members.copyInviteLabel', 'Invite Link kopieren')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{loading ? (
|
||||
<YStack space="$2">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<MobileCard key={`m-${idx}`} height={70} opacity={0.6} />
|
||||
))}
|
||||
</YStack>
|
||||
) : members.length === 0 ? (
|
||||
<Text fontSize="$sm" color="#4b5563">
|
||||
{t('events.members.empty', 'Noch keine Einladungen.')}
|
||||
</Text>
|
||||
) : (
|
||||
<YStack space="$2">
|
||||
{members
|
||||
.filter((member) => {
|
||||
if (!search.trim()) return true;
|
||||
const hay = `${member.name ?? ''} ${member.email ?? ''}`.toLowerCase();
|
||||
return hay.includes(search.toLowerCase());
|
||||
})
|
||||
.map((member) => (
|
||||
<MobileCard key={member.id} padding="$3" borderColor="#e5e7eb">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack space="$1">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
{member.name || member.email || 'Gast'}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
{member.email ?? ''}
|
||||
</Text>
|
||||
<XStack space="$1.5" alignItems="center">
|
||||
<PillBadge tone={member.status === 'pending' ? 'warning' : 'muted'}>
|
||||
{member.status ?? 'pending'}
|
||||
</PillBadge>
|
||||
<PillBadge tone={member.role === 'tenant_admin' ? 'success' : 'muted'}>
|
||||
{member.role === 'tenant_admin'
|
||||
? t('events.members.admin', 'Admin')
|
||||
: t('events.members.member', 'Member')}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
</YStack>
|
||||
<XStack space="$2">
|
||||
<Pressable
|
||||
onPress={async () => {
|
||||
const link = inviteLink || (member.email ? `mailto:${member.email}` : null);
|
||||
if (!link) {
|
||||
toast.error(t('events.members.copyInviteFailed', 'Kopieren nicht möglich'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
toast.success(t('events.members.copyInvite', 'Einladungslink kopiert'));
|
||||
} catch {
|
||||
toast.error(t('events.members.copyInviteFailed', 'Kopieren nicht möglich'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy size={16} color="#6b7280" />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => setConfirmRemove(member)}>
|
||||
<Trash2 size={16} color="#ef4444" />
|
||||
</Pressable>
|
||||
</XStack>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
))}
|
||||
</YStack>
|
||||
)}
|
||||
</MobileCard>
|
||||
|
||||
<MobileSheet
|
||||
open={Boolean(confirmRemove)}
|
||||
onClose={() => setConfirmRemove(null)}
|
||||
title={t('events.members.confirmRemove', 'Mitglied entfernen?')}
|
||||
footer={
|
||||
<CTAButton
|
||||
label={t('events.members.remove', 'Remove')}
|
||||
onPress={() => {
|
||||
if (confirmRemove) {
|
||||
void handleRemove(confirmRemove);
|
||||
}
|
||||
setConfirmRemove(null);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
bottomOffsetPx={120}
|
||||
>
|
||||
<Text fontSize={12.5} color="#4b5563">
|
||||
{t('events.members.removeHint', 'Dieses Mitglied verliert den Zugang zum Event.')}
|
||||
</Text>
|
||||
<Text fontSize={13} fontWeight="700" color="#111827">
|
||||
{confirmRemove?.name || confirmRemove?.email}
|
||||
</Text>
|
||||
</MobileSheet>
|
||||
</MobileScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<YStack space="$1.5">
|
||||
<Text fontSize="$sm" fontWeight="800" color="#111827">
|
||||
{label}
|
||||
</Text>
|
||||
{children}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: 42,
|
||||
borderRadius: 10,
|
||||
border: '1px solid #e5e7eb',
|
||||
padding: '0 12px',
|
||||
fontSize: 14,
|
||||
background: 'white',
|
||||
};
|
||||
339
resources/js/admin/mobile/EventPhotosPage.tsx
Normal file
339
resources/js/admin/mobile/EventPhotosPage.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Image as ImageIcon, RefreshCcw, Search, Filter } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { MobileScaffold } from './components/Scaffold';
|
||||
import { MobileCard, PillBadge, CTAButton } from './components/Primitives';
|
||||
import { BottomNav } from './components/BottomNav';
|
||||
import { getEventPhotos, updatePhotoVisibility, featurePhoto, unfeaturePhoto, TenantPhoto } from '../api';
|
||||
import toast from 'react-hot-toast';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { useMobileNav } from './hooks/useMobileNav';
|
||||
import { MobileSheet } from './components/Sheet';
|
||||
|
||||
type FilterKey = 'all' | 'featured' | 'hidden';
|
||||
|
||||
export default function MobileEventPhotosPage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const slug = slugParam ?? null;
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const [photos, setPhotos] = React.useState<TenantPhoto[]>([]);
|
||||
const [filter, setFilter] = React.useState<FilterKey>('all');
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [busyId, setBusyId] = React.useState<number | null>(null);
|
||||
const [totalCount, setTotalCount] = React.useState<number>(0);
|
||||
const [hasMore, setHasMore] = React.useState(false);
|
||||
const { go } = useMobileNav(slug);
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [showFilters, setShowFilters] = React.useState(false);
|
||||
const [uploaderFilter, setUploaderFilter] = React.useState('');
|
||||
const [onlyFeatured, setOnlyFeatured] = React.useState(false);
|
||||
const [onlyHidden, setOnlyHidden] = React.useState(false);
|
||||
const [lightbox, setLightbox] = React.useState<TenantPhoto | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!slug) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getEventPhotos(slug, {
|
||||
page,
|
||||
perPage: 20,
|
||||
sort: 'desc',
|
||||
featured: filter === 'featured' || onlyFeatured,
|
||||
status: filter === 'hidden' || onlyHidden ? 'hidden' : undefined,
|
||||
search: search || undefined,
|
||||
uploader: uploaderFilter || undefined,
|
||||
});
|
||||
setPhotos((prev) => (page === 1 ? result.photos : [...prev, ...result.photos]));
|
||||
setTotalCount(result.meta?.total ?? result.photos.length);
|
||||
const lastPage = result.meta?.last_page ?? 1;
|
||||
setHasMore(page < lastPage);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Fotos konnten nicht geladen werden.')));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [slug, filter, t, page]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
}, [filter, slug]);
|
||||
|
||||
async function toggleVisibility(photo: TenantPhoto) {
|
||||
if (!slug) return;
|
||||
setBusyId(photo.id);
|
||||
try {
|
||||
const updated = await updatePhotoVisibility(slug, photo.id, photo.status === 'hidden');
|
||||
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p)));
|
||||
toast.success(
|
||||
updated.status === 'hidden'
|
||||
? t('events.photos.hideSuccess', 'Foto versteckt')
|
||||
: t('events.photos.showSuccess', 'Foto eingeblendet'),
|
||||
);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Sichtbarkeit konnte nicht geändert werden.')));
|
||||
toast.error(t('events.photos.hideFailed', 'Sichtbarkeit konnte nicht geändert werden.'));
|
||||
}
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFeature(photo: TenantPhoto) {
|
||||
if (!slug) return;
|
||||
setBusyId(photo.id);
|
||||
try {
|
||||
const updated = photo.is_featured ? await unfeaturePhoto(slug, photo.id) : await featurePhoto(slug, photo.id);
|
||||
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p)));
|
||||
toast.success(
|
||||
updated.is_featured
|
||||
? t('events.photos.featureSuccess', 'Als Highlight markiert')
|
||||
: t('events.photos.unfeatureSuccess', 'Highlight entfernt'),
|
||||
);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Feature konnte nicht geändert werden.')));
|
||||
toast.error(t('events.photos.featureFailed', 'Feature konnte nicht geändert werden.'));
|
||||
}
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileScaffold
|
||||
title={t('events.photos.title', 'Photo Moderation')}
|
||||
onBack={() => navigate(-1)}
|
||||
rightSlot={
|
||||
<XStack space="$3">
|
||||
<Pressable onPress={() => setShowFilters(true)}>
|
||||
<Filter size={18} color="#0f172a" />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => load()}>
|
||||
<RefreshCcw size={18} color="#0f172a" />
|
||||
</Pressable>
|
||||
</XStack>
|
||||
}
|
||||
footer={
|
||||
<BottomNav active="events" onNavigate={go} />
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color="#b91c1c">
|
||||
{error}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder={t('events.photos.search', 'Search photos')}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 38,
|
||||
borderRadius: 10,
|
||||
border: '1px solid #e5e7eb',
|
||||
padding: '0 12px',
|
||||
fontSize: 13,
|
||||
background: 'white',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
/>
|
||||
|
||||
<XStack space="$2">
|
||||
{(['all', 'featured', 'hidden'] as FilterKey[]).map((key) => (
|
||||
<Pressable key={key} onPress={() => setFilter(key)} style={{ flex: 1 }}>
|
||||
<MobileCard
|
||||
backgroundColor={filter === key ? '#e8f1ff' : 'white'}
|
||||
borderColor={filter === key ? '#bfdbfe' : '#e5e7eb'}
|
||||
padding="$2.5"
|
||||
>
|
||||
<Text fontSize="$sm" fontWeight="700" textAlign="center" color="#111827">
|
||||
{key === 'all' ? t('common.all', 'All') : key === 'featured' ? t('events.photos.featured', 'Featured') : t('events.photos.hidden', 'Hidden')}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
</Pressable>
|
||||
))}
|
||||
</XStack>
|
||||
|
||||
{loading ? (
|
||||
<YStack space="$2">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<MobileCard key={`ph-${idx}`} height={100} opacity={0.6} />
|
||||
))}
|
||||
</YStack>
|
||||
) : photos.length === 0 ? (
|
||||
<MobileCard alignItems="center" justifyContent="center" space="$2">
|
||||
<ImageIcon size={28} color="#9ca3af" />
|
||||
<Text fontSize="$sm" color="#4b5563">
|
||||
{t('events.photos.empty', 'Keine Fotos gefunden.')}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : (
|
||||
<YStack space="$3">
|
||||
<Text fontSize="$sm" color="#4b5563">
|
||||
{t('events.photos.count', '{{count}} Fotos', { count: totalCount })}
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{photos.map((photo) => (
|
||||
<Pressable key={photo.id} onPress={() => setLightbox(photo)}>
|
||||
<YStack borderRadius={10} overflow="hidden" borderWidth={1} borderColor="#e5e7eb">
|
||||
<img
|
||||
src={photo.thumbnail_url ?? photo.url ?? undefined}
|
||||
alt={photo.caption ?? 'Photo'}
|
||||
style={{ width: '100%', height: 110, objectFit: 'cover' }}
|
||||
/>
|
||||
<XStack position="absolute" top={6} left={6} space="$1">
|
||||
{photo.is_featured ? <PillBadge tone="warning">{t('events.photos.featured', 'Featured')}</PillBadge> : null}
|
||||
{photo.status === 'hidden' ? <PillBadge tone="muted">{t('events.photos.hidden', 'Hidden')}</PillBadge> : null}
|
||||
</XStack>
|
||||
</YStack>
|
||||
</Pressable>
|
||||
))}
|
||||
</div>
|
||||
{hasMore ? (
|
||||
<CTAButton label={t('common.loadMore', 'Mehr laden')} onPress={() => setPage((prev) => prev + 1)} />
|
||||
) : null}
|
||||
</YStack>
|
||||
)}
|
||||
|
||||
{lightbox ? (
|
||||
<div className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center">
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 520,
|
||||
margin: '0 16px',
|
||||
background: '#fff',
|
||||
borderRadius: 20,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={lightbox.url ?? lightbox.thumbnail_url ?? undefined}
|
||||
alt={lightbox.caption ?? 'Photo'}
|
||||
style={{ width: '100%', maxHeight: '60vh', objectFit: 'contain', background: '#0f172a' }}
|
||||
/>
|
||||
<YStack padding="$3" space="$2">
|
||||
<XStack space="$2" alignItems="center">
|
||||
<PillBadge tone="muted">{lightbox.uploader_name || t('events.photos.guest', 'Gast')}</PillBadge>
|
||||
<PillBadge tone="muted">❤️ {lightbox.likes_count ?? 0}</PillBadge>
|
||||
</XStack>
|
||||
<XStack space="$2">
|
||||
<CTAButton
|
||||
label={
|
||||
busyId === lightbox.id
|
||||
? t('common.processing', '...')
|
||||
: lightbox.is_featured
|
||||
? t('events.photos.unfeature', 'Unfeature')
|
||||
: t('events.photos.feature', 'Feature')
|
||||
}
|
||||
onPress={() => toggleFeature(lightbox)}
|
||||
/>
|
||||
<CTAButton
|
||||
label={
|
||||
busyId === lightbox.id
|
||||
? t('common.processing', '...')
|
||||
: lightbox.status === 'hidden'
|
||||
? t('events.photos.show', 'Show')
|
||||
: t('events.photos.hide', 'Hide')
|
||||
}
|
||||
onPress={() => toggleVisibility(lightbox)}
|
||||
/>
|
||||
</XStack>
|
||||
<CTAButton label={t('common.close', 'Close')} onPress={() => setLightbox(null)} />
|
||||
</YStack>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<MobileSheet
|
||||
open={showFilters}
|
||||
onClose={() => setShowFilters(false)}
|
||||
title={t('events.photos.filters', 'Filter')}
|
||||
footer={
|
||||
<CTAButton
|
||||
label={t('events.photos.applyFilters', 'Apply filters')}
|
||||
onPress={() => {
|
||||
setPage(1);
|
||||
setShowFilters(false);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<YStack space="$2">
|
||||
<Field label={t('events.photos.uploader', 'Uploader')}>
|
||||
<input
|
||||
type="text"
|
||||
value={uploaderFilter}
|
||||
onChange={(e) => setUploaderFilter(e.target.value)}
|
||||
placeholder={t('events.photos.uploaderPlaceholder', 'Name or email')}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<XStack space="$2" alignItems="center">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyFeatured}
|
||||
onChange={(e) => setOnlyFeatured(e.target.checked)}
|
||||
/>
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{t('events.photos.onlyFeatured', 'Only featured')}
|
||||
</Text>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyHidden}
|
||||
onChange={(e) => setOnlyHidden(e.target.checked)}
|
||||
/>
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{t('events.photos.onlyHidden', 'Only hidden')}
|
||||
</Text>
|
||||
</label>
|
||||
</XStack>
|
||||
<CTAButton
|
||||
label={t('common.reset', 'Reset')}
|
||||
tone="ghost"
|
||||
onPress={() => {
|
||||
setUploaderFilter('');
|
||||
setOnlyFeatured(false);
|
||||
setOnlyHidden(false);
|
||||
}}
|
||||
/>
|
||||
</YStack>
|
||||
</MobileSheet>
|
||||
</MobileScaffold>
|
||||
);
|
||||
}
|
||||
770
resources/js/admin/mobile/EventTasksPage.tsx
Normal file
770
resources/js/admin/mobile/EventTasksPage.tsx
Normal file
@@ -0,0 +1,770 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RefreshCcw, Plus, Folder, Pencil, Trash2, MoreHorizontal } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { ListItem } from '@tamagui/list-item';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { MobileScaffold } from './components/Scaffold';
|
||||
import { MobileCard, CTAButton } from './components/Primitives';
|
||||
import { BottomNav } from './components/BottomNav';
|
||||
import {
|
||||
getEvent,
|
||||
getEventTasks,
|
||||
updateTask,
|
||||
TenantTask,
|
||||
assignTasksToEvent,
|
||||
getTasks,
|
||||
getTaskCollections,
|
||||
importTaskCollection,
|
||||
createTask,
|
||||
TenantTaskCollection,
|
||||
getEmotions,
|
||||
TenantEmotion,
|
||||
detachTasksFromEvent,
|
||||
createEmotion,
|
||||
updateEmotion as updateEmotionApi,
|
||||
deleteEmotion as deleteEmotionApi,
|
||||
} from '../api';
|
||||
import { adminPath } from '../constants';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import toast from 'react-hot-toast';
|
||||
import { MobileSheet } from './components/Sheet';
|
||||
import { Tag } from './components/Tag';
|
||||
import { useMobileNav } from './hooks/useMobileNav';
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
border: '1px solid #e5e7eb',
|
||||
padding: '0 12px',
|
||||
fontSize: 13,
|
||||
background: 'white',
|
||||
};
|
||||
|
||||
function InlineSeparator() {
|
||||
return <XStack height={1} backgroundColor="#e5e7eb" opacity={0.7} marginLeft="$3" />;
|
||||
}
|
||||
|
||||
export default function MobileEventTasksPage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const slug = slugParam ?? null;
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const [tasks, setTasks] = React.useState<TenantTask[]>([]);
|
||||
const [library, setLibrary] = React.useState<TenantTask[]>([]);
|
||||
const [collections, setCollections] = React.useState<TenantTaskCollection[]>([]);
|
||||
const [emotions, setEmotions] = React.useState<TenantEmotion[]>([]);
|
||||
const [showCollectionSheet, setShowCollectionSheet] = React.useState(false);
|
||||
const [showTaskSheet, setShowTaskSheet] = React.useState(false);
|
||||
const [newTask, setNewTask] = React.useState({ id: null as number | null, title: '', description: '', emotion_id: '' as string | '' });
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [busyId, setBusyId] = React.useState<number | null>(null);
|
||||
const [assigningId, setAssigningId] = React.useState<number | null>(null);
|
||||
const [eventId, setEventId] = React.useState<number | null>(null);
|
||||
const { go } = useMobileNav(slug);
|
||||
const [searchTerm, setSearchTerm] = React.useState('');
|
||||
const [emotionFilter, setEmotionFilter] = React.useState<string>('');
|
||||
const [expandedLibrary, setExpandedLibrary] = React.useState(false);
|
||||
const [expandedCollections, setExpandedCollections] = React.useState(false);
|
||||
const [showActionsSheet, setShowActionsSheet] = React.useState(false);
|
||||
const [showBulkSheet, setShowBulkSheet] = React.useState(false);
|
||||
const [bulkLines, setBulkLines] = React.useState('');
|
||||
const [showEmotionSheet, setShowEmotionSheet] = React.useState(false);
|
||||
const [editingEmotion, setEditingEmotion] = React.useState<TenantEmotion | null>(null);
|
||||
const [emotionForm, setEmotionForm] = React.useState({ name: '', color: '#e5e7eb' });
|
||||
const [savingEmotion, setSavingEmotion] = React.useState(false);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!slug) {
|
||||
setError(t('events.errors.missingSlug', 'Kein Event-Slug angegeben.'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const event = await getEvent(slug);
|
||||
setEventId(event.id);
|
||||
const result = await getEventTasks(event.id, 1);
|
||||
const libraryTasks = await getTasks({ per_page: 50 });
|
||||
const collectionList = await getTaskCollections({ per_page: 50 });
|
||||
const emotionList = await getEmotions();
|
||||
setTasks(result.data);
|
||||
setLibrary(libraryTasks.data.filter((task) => !result.data.find((t) => t.id === task.id)));
|
||||
setCollections(collectionList.data ?? []);
|
||||
setEmotions(emotionList ?? []);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Tasks konnten nicht geladen werden.')));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [slug, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function quickAssign(taskId: number) {
|
||||
if (!eventId) return;
|
||||
setAssigningId(taskId);
|
||||
try {
|
||||
await assignTasksToEvent(eventId, [taskId]);
|
||||
const result = await getEventTasks(eventId, 1);
|
||||
setTasks(result.data);
|
||||
setLibrary((prev) => prev.filter((t) => t.id !== taskId));
|
||||
toast.success(t('events.tasks.assigned', 'Task hinzugefügt'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Task konnte nicht zugewiesen werden.')));
|
||||
toast.error(t('events.tasks.updateFailed', 'Task konnte nicht zugewiesen werden.'));
|
||||
}
|
||||
} finally {
|
||||
setAssigningId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function importCollection(collectionId: number) {
|
||||
if (!eventId) return;
|
||||
try {
|
||||
await importTaskCollection(collectionId, eventId);
|
||||
const result = await getEventTasks(eventId, 1);
|
||||
setTasks(result.data);
|
||||
toast.success(t('events.tasks.imported', 'Aufgabenpaket importiert'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Paket konnte nicht importiert werden.')));
|
||||
toast.error(t('events.errors.saveFailed', 'Paket konnte nicht importiert werden.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createNewTask() {
|
||||
if (!eventId || !newTask.title.trim()) return;
|
||||
try {
|
||||
if (newTask.id) {
|
||||
await updateTask(newTask.id, {
|
||||
title: newTask.title.trim(),
|
||||
description: newTask.description.trim() || null,
|
||||
emotion_id: newTask.emotion_id ? Number(newTask.emotion_id) : undefined,
|
||||
} as any);
|
||||
} else {
|
||||
const created = await createTask({
|
||||
title: newTask.title.trim(),
|
||||
description: newTask.description.trim() || null,
|
||||
emotion_id: newTask.emotion_id ? Number(newTask.emotion_id) : undefined,
|
||||
} as any);
|
||||
await assignTasksToEvent(eventId, [created.id]);
|
||||
}
|
||||
const result = await getEventTasks(eventId, 1);
|
||||
setTasks(result.data);
|
||||
setShowTaskSheet(false);
|
||||
setNewTask({ id: null, title: '', description: '', emotion_id: '' });
|
||||
toast.success(t('events.tasks.created', 'Aufgabe gespeichert'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Aufgabe konnte nicht erstellt werden.')));
|
||||
toast.error(t('events.errors.saveFailed', 'Aufgabe konnte nicht erstellt werden.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function detachTask(taskId: number) {
|
||||
if (!eventId) return;
|
||||
setBusyId(taskId);
|
||||
try {
|
||||
await detachTasksFromEvent(eventId, [taskId]);
|
||||
setTasks((prev) => prev.filter((task) => task.id !== taskId));
|
||||
toast.success(t('events.tasks.removed', 'Aufgabe entfernt'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Aufgabe konnte nicht entfernt werden.')));
|
||||
toast.error(t('events.errors.saveFailed', 'Aufgabe konnte nicht entfernt werden.'));
|
||||
}
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const startEdit = (task: TenantTask) => {
|
||||
setNewTask({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
description: task.description ?? '',
|
||||
emotion_id: task.emotion?.id ? String(task.emotion.id) : '',
|
||||
});
|
||||
setShowTaskSheet(true);
|
||||
};
|
||||
|
||||
const filteredTasks = tasks.filter((task) => {
|
||||
const matchText =
|
||||
!searchTerm ||
|
||||
task.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(task.description ?? '').toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchEmotion = !emotionFilter || task.emotion?.id === Number(emotionFilter);
|
||||
return matchText && matchEmotion;
|
||||
});
|
||||
|
||||
async function handleBulkAdd() {
|
||||
if (!eventId || !bulkLines.trim()) return;
|
||||
const lines = bulkLines
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
if (!lines.length) return;
|
||||
try {
|
||||
for (const line of lines) {
|
||||
const created = await createTask({ title: line } as any);
|
||||
await assignTasksToEvent(eventId, [created.id]);
|
||||
}
|
||||
const result = await getEventTasks(eventId, 1);
|
||||
setTasks(result.data);
|
||||
setBulkLines('');
|
||||
setShowBulkSheet(false);
|
||||
toast.success(t('events.tasks.created', 'Aufgabe gespeichert'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
toast.error(t('events.errors.saveFailed', 'Aufgabe konnte nicht erstellt werden.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEmotion() {
|
||||
if (!emotionForm.name.trim()) return;
|
||||
setSavingEmotion(true);
|
||||
try {
|
||||
if (editingEmotion) {
|
||||
const updated = await updateEmotionApi(editingEmotion.id, { name: emotionForm.name.trim(), color: emotionForm.color });
|
||||
setEmotions((prev) => prev.map((em) => (em.id === editingEmotion.id ? updated : em)));
|
||||
} else {
|
||||
const created = await createEmotion({ name: emotionForm.name.trim(), color: emotionForm.color });
|
||||
setEmotions((prev) => [...prev, created]);
|
||||
}
|
||||
setShowEmotionSheet(false);
|
||||
setEditingEmotion(null);
|
||||
setEmotionForm({ name: '', color: '#e5e7eb' });
|
||||
toast.success(t('events.tasks.emotionSaved', 'Emotion gespeichert'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
toast.error(getApiErrorMessage(err, t('events.errors.saveFailed', 'Konnte nicht gespeichert werden.')));
|
||||
}
|
||||
} finally {
|
||||
setSavingEmotion(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeEmotion(emotionId: number) {
|
||||
try {
|
||||
await deleteEmotionApi(emotionId);
|
||||
setEmotions((prev) => prev.filter((em) => em.id !== emotionId));
|
||||
toast.success(t('events.tasks.emotionRemoved', 'Emotion entfernt'));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
toast.error(getApiErrorMessage(err, t('events.errors.saveFailed', 'Konnte nicht gespeichert werden.')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileScaffold
|
||||
title={t('events.tasks.title', 'Tasks & Checklists')}
|
||||
onBack={() => navigate(-1)}
|
||||
rightSlot={
|
||||
<XStack space="$2">
|
||||
<Pressable onPress={() => load()}>
|
||||
<RefreshCcw size={18} color="#0f172a" />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => setShowActionsSheet(true)}>
|
||||
<MoreHorizontal size={18} color="#0f172a" />
|
||||
</Pressable>
|
||||
</XStack>
|
||||
}
|
||||
footer={
|
||||
<BottomNav active="tasks" onNavigate={go} />
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontSize={13} fontWeight="600" color="#b91c1c">
|
||||
{error}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<YStack space="$2">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<MobileCard key={`tsk-${idx}`} height={70} opacity={0.6} />
|
||||
))}
|
||||
</YStack>
|
||||
) : tasks.length === 0 ? (
|
||||
<MobileCard alignItems="center" justifyContent="center" space="$2">
|
||||
<Text fontSize={13} fontWeight="500" color="#4b5563">
|
||||
{t('events.tasks.empty', 'Noch keine Aufgaben.')}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : (
|
||||
<YStack space="$2">
|
||||
<YStack space="$2">
|
||||
<input
|
||||
type="search"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder={t('events.tasks.search', 'Search tasks')}
|
||||
style={{ ...inputStyle, height: 38 }}
|
||||
/>
|
||||
<XStack space="$2" flexWrap="wrap">
|
||||
<Chip
|
||||
active={!emotionFilter}
|
||||
label={t('events.tasks.allEmotions', 'All')}
|
||||
onPress={() => setEmotionFilter('')}
|
||||
/>
|
||||
{emotions.map((emotion) => (
|
||||
<Chip
|
||||
key={emotion.id}
|
||||
label={emotion.name}
|
||||
color={emotion.color ?? '#e5e7eb'}
|
||||
active={emotionFilter === String(emotion.id)}
|
||||
onPress={() => setEmotionFilter(String(emotion.id))}
|
||||
/>
|
||||
))}
|
||||
</XStack>
|
||||
</YStack>
|
||||
|
||||
<Text fontSize="$sm" color="#4b5563">
|
||||
{t('events.tasks.count', '{{count}} Tasks', { count: filteredTasks.length })}
|
||||
</Text>
|
||||
<YStack borderWidth={1} borderColor="#e5e7eb" borderRadius="$4" overflow="hidden">
|
||||
{filteredTasks.map((task, idx) => (
|
||||
<React.Fragment key={task.id}>
|
||||
<ListItem
|
||||
title={
|
||||
<Text fontSize={12.5} fontWeight="600" color="#111827">
|
||||
{task.title}
|
||||
</Text>
|
||||
}
|
||||
subTitle={
|
||||
task.description ? (
|
||||
<Text fontSize={11.5} fontWeight="400" color="#6b7280">
|
||||
{task.description}
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
iconAfter={
|
||||
<XStack space="$2">
|
||||
<Pressable onPress={() => startEdit(task)}>
|
||||
<Pencil size={14} color="#007AFF" />
|
||||
</Pressable>
|
||||
<Pressable disabled={busyId === task.id} onPress={() => detachTask(task.id)}>
|
||||
<Trash2 size={14} color="#ef4444" />
|
||||
</Pressable>
|
||||
</XStack>
|
||||
}
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
>
|
||||
{task.emotion ? (
|
||||
<XStack marginTop="$1">
|
||||
<Tag label={task.emotion.name ?? ''} color={task.emotion.color ?? '#e5e7eb'} />
|
||||
</XStack>
|
||||
) : null}
|
||||
</ListItem>
|
||||
{idx < tasks.length - 1 ? <InlineSeparator /> : null}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</YStack>
|
||||
<XStack justifyContent="space-between" alignItems="center" marginTop="$2">
|
||||
<Text fontSize={12.5} fontWeight="600" color="#111827">
|
||||
{t('events.tasks.library', 'Weitere Aufgaben')}
|
||||
</Text>
|
||||
<Pressable onPress={() => setShowCollectionSheet(true)}>
|
||||
<Text fontSize={12} fontWeight="600" color="#007AFF">
|
||||
{t('events.tasks.import', 'Import Pack')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</XStack>
|
||||
<Pressable onPress={() => setExpandedLibrary((prev) => !prev)}>
|
||||
<Text fontSize={12} fontWeight="600" color="#007AFF">
|
||||
{expandedLibrary ? t('events.tasks.hideLibrary', 'Hide library') : t('events.tasks.viewAllLibrary', 'View all')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{library.length === 0 ? (
|
||||
<Text fontSize={12} fontWeight="500" color="#6b7280">
|
||||
{t('events.tasks.libraryEmpty', 'Keine weiteren Aufgaben verfügbar.')}
|
||||
</Text>
|
||||
) : (
|
||||
<YStack borderWidth={1} borderColor="#e5e7eb" borderRadius="$4" overflow="hidden">
|
||||
{(expandedLibrary ? library : library.slice(0, 6)).map((task, idx, arr) => (
|
||||
<React.Fragment key={`lib-${task.id}`}>
|
||||
<ListItem
|
||||
title={
|
||||
<Text fontSize={12.5} fontWeight="600" color="#111827">
|
||||
{task.title}
|
||||
</Text>
|
||||
}
|
||||
subTitle={
|
||||
task.description ? (
|
||||
<Text fontSize={11.5} fontWeight="400" color="#6b7280">
|
||||
{task.description}
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
iconAfter={
|
||||
<Pressable onPress={() => quickAssign(task.id)}>
|
||||
<Text fontSize={12} fontWeight="600" color="#007AFF">
|
||||
{assigningId === task.id ? t('common.processing', '...') : t('events.tasks.add', 'Add')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
}
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
/>
|
||||
{idx < arr.length - 1 ? <InlineSeparator /> : null}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</YStack>
|
||||
)}
|
||||
</YStack>
|
||||
)}
|
||||
|
||||
<MobileSheet
|
||||
open={showCollectionSheet}
|
||||
onClose={() => setShowCollectionSheet(false)}
|
||||
title={t('events.tasks.import', 'Aufgabenpaket importieren')}
|
||||
footer={null}
|
||||
>
|
||||
<YStack space="$2">
|
||||
<Pressable onPress={() => setExpandedCollections((prev) => !prev)}>
|
||||
<Text fontSize={12} fontWeight="600" color="#007AFF">
|
||||
{expandedCollections ? t('events.tasks.hideCollections', 'Hide collections') : t('events.tasks.showCollections', 'Show all')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{collections.length === 0 ? (
|
||||
<Text fontSize={13} fontWeight="500" color="#4b5563">
|
||||
{t('events.tasks.collectionsEmpty', 'Keine Pakete vorhanden.')}
|
||||
</Text>
|
||||
) : (
|
||||
<YStack borderWidth={1} borderColor="#e5e7eb" borderRadius="$4" overflow="hidden">
|
||||
{(expandedCollections ? collections : collections.slice(0, 6)).map((collection, idx, arr) => (
|
||||
<React.Fragment key={collection.id}>
|
||||
<ListItem
|
||||
title={
|
||||
<Text fontSize={12.5} fontWeight="600" color="#111827">
|
||||
{collection.title}
|
||||
</Text>
|
||||
}
|
||||
subTitle={
|
||||
collection.description ? (
|
||||
<Text fontSize={11.5} fontWeight="400" color="#6b7280">
|
||||
{collection.description}
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
iconAfter={
|
||||
<Pressable onPress={() => importCollection(collection.id)}>
|
||||
<Text fontSize={12} fontWeight="600" color="#007AFF">
|
||||
{t('events.tasks.import', 'Import')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
}
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
/>
|
||||
{idx < arr.length - 1 ? <InlineSeparator /> : null}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</YStack>
|
||||
)}
|
||||
</YStack>
|
||||
</MobileSheet>
|
||||
|
||||
<MobileSheet
|
||||
open={showTaskSheet}
|
||||
onClose={() => setShowTaskSheet(false)}
|
||||
title={t('events.tasks.addTask', 'Aufgabe hinzufügen')}
|
||||
footer={
|
||||
<CTAButton label={t('events.tasks.saveTask', 'Aufgabe speichern')} onPress={() => createNewTask()} />
|
||||
}
|
||||
>
|
||||
<YStack space="$2">
|
||||
<Field label={t('events.tasks.title', 'Titel')}>
|
||||
<input
|
||||
type="text"
|
||||
value={newTask.title}
|
||||
onChange={(e) => setNewTask((prev) => ({ ...prev, title: e.target.value }))}
|
||||
placeholder={t('events.tasks.titlePlaceholder', 'z.B. Erstes Gruppenfoto')}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('events.tasks.description', 'Beschreibung')}>
|
||||
<textarea
|
||||
value={newTask.description}
|
||||
onChange={(e) => setNewTask((prev) => ({ ...prev, description: e.target.value }))}
|
||||
placeholder={t('events.tasks.descriptionPlaceholder', 'Optionale Hinweise')}
|
||||
style={{ ...inputStyle, minHeight: 80 }}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('events.tasks.emotion', 'Emotion')}>
|
||||
<select
|
||||
value={newTask.emotion_id}
|
||||
onChange={(e) => setNewTask((prev) => ({ ...prev, emotion_id: e.target.value }))}
|
||||
style={{ ...inputStyle, height: 42 }}
|
||||
>
|
||||
<option value="">{t('events.tasks.emotionNone', 'Keine')}</option>
|
||||
{emotions.map((emotion) => (
|
||||
<option key={emotion.id} value={emotion.id}>
|
||||
{emotion.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</YStack>
|
||||
</MobileSheet>
|
||||
|
||||
<MobileSheet
|
||||
open={showActionsSheet}
|
||||
onClose={() => setShowActionsSheet(false)}
|
||||
title={t('events.tasks.moreActions', 'Mehr Aktionen')}
|
||||
footer={null}
|
||||
>
|
||||
<YStack space="$2">
|
||||
<ListItem
|
||||
title={
|
||||
<Text fontSize={12.5} fontWeight="600" color="#111827">
|
||||
{t('events.tasks.bulkAdd', 'Bulk add')}
|
||||
</Text>
|
||||
}
|
||||
onPress={() => {
|
||||
setShowActionsSheet(false);
|
||||
setShowBulkSheet(true);
|
||||
}}
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
/>
|
||||
<ListItem
|
||||
title={
|
||||
<Text fontSize={12.5} fontWeight="600" color="#111827">
|
||||
{t('events.tasks.manageEmotions', 'Manage emotions')}
|
||||
</Text>
|
||||
}
|
||||
subTitle={
|
||||
<Text fontSize={11.5} fontWeight="400" color="#6b7280">
|
||||
{t('events.tasks.manageEmotionsHint', 'Filter and keep your taxonomy tidy.')}
|
||||
</Text>
|
||||
}
|
||||
onPress={() => {
|
||||
setShowActionsSheet(false);
|
||||
setShowEmotionSheet(true);
|
||||
}}
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$3"
|
||||
/>
|
||||
</YStack>
|
||||
</MobileSheet>
|
||||
|
||||
<MobileSheet
|
||||
open={showBulkSheet}
|
||||
onClose={() => setShowBulkSheet(false)}
|
||||
title={t('events.tasks.bulkAdd', 'Bulk add')}
|
||||
footer={<CTAButton label={t('events.tasks.saveTask', 'Aufgabe speichern')} onPress={() => handleBulkAdd()} />}
|
||||
>
|
||||
<YStack space="$2">
|
||||
<Text fontSize={12} color="#4b5563">
|
||||
{t('events.tasks.bulkHint', 'One task per line. These will be created and added to the event.')}
|
||||
</Text>
|
||||
<textarea
|
||||
value={bulkLines}
|
||||
onChange={(e) => setBulkLines(e.target.value)}
|
||||
placeholder={t('events.tasks.bulkPlaceholder', 'e.g.\nBride & groom portrait\nGroup photo main guests')}
|
||||
style={{ ...inputStyle, minHeight: 140, fontSize: 12.5 }}
|
||||
/>
|
||||
</YStack>
|
||||
</MobileSheet>
|
||||
|
||||
<MobileSheet
|
||||
open={showEmotionSheet}
|
||||
onClose={() => {
|
||||
setShowEmotionSheet(false);
|
||||
setEditingEmotion(null);
|
||||
setEmotionForm({ name: '', color: '#e5e7eb' });
|
||||
}}
|
||||
title={t('events.tasks.manageEmotions', 'Manage emotions')}
|
||||
footer={
|
||||
<CTAButton
|
||||
label={savingEmotion ? t('common.saving', 'Saving...') : t('events.tasks.saveEmotion', 'Emotion speichern')}
|
||||
onPress={() => saveEmotion()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<YStack space="$2">
|
||||
<Field label={t('events.tasks.emotionName', 'Name')}>
|
||||
<input
|
||||
type="text"
|
||||
value={emotionForm.name}
|
||||
onChange={(e) => setEmotionForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder={t('events.tasks.emotionNamePlaceholder', 'z.B. Joy')}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('events.tasks.emotionColor', 'Farbe')}>
|
||||
<input
|
||||
type="color"
|
||||
value={emotionForm.color}
|
||||
onChange={(e) => setEmotionForm((prev) => ({ ...prev, color: e.target.value }))}
|
||||
style={{ width: '100%', height: 44, borderRadius: 10, border: '1px solid #e5e7eb', background: 'white' }}
|
||||
/>
|
||||
</Field>
|
||||
<YStack space="$2">
|
||||
{emotions.map((em) => (
|
||||
<ListItem
|
||||
key={`emo-${em.id}`}
|
||||
title={
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Tag label={em.name ?? ''} color={em.color ?? '#e5e7eb'} />
|
||||
</XStack>
|
||||
}
|
||||
iconAfter={
|
||||
<XStack space="$2">
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setEditingEmotion(em);
|
||||
setEmotionForm({ name: em.name ?? '', color: em.color ?? '#e5e7eb' });
|
||||
}}
|
||||
>
|
||||
<Pencil size={14} color="#007AFF" />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => removeEmotion(em.id)}>
|
||||
<Trash2 size={14} color="#ef4444" />
|
||||
</Pressable>
|
||||
</XStack>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</YStack>
|
||||
</YStack>
|
||||
</MobileSheet>
|
||||
|
||||
<MobileSheet
|
||||
open={showEmotionSheet}
|
||||
onClose={() => {
|
||||
setShowEmotionSheet(false);
|
||||
setEditingEmotion(null);
|
||||
setEmotionForm({ name: '', color: '#e5e7eb' });
|
||||
}}
|
||||
title={t('events.tasks.manageEmotions', 'Manage emotions')}
|
||||
footer={
|
||||
<CTAButton
|
||||
label={t('events.tasks.saveEmotion', 'Emotion speichern')}
|
||||
onPress={() => {
|
||||
void saveEmotion();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<YStack space="$2">
|
||||
<Field label={t('events.tasks.emotionName', 'Name')}>
|
||||
<input
|
||||
type="text"
|
||||
value={emotionForm.name}
|
||||
onChange={(e) => setEmotionForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder={t('events.tasks.emotionNamePlaceholder', 'z.B. Joy')}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('events.tasks.emotionColor', 'Farbe')}>
|
||||
<input
|
||||
type="color"
|
||||
value={emotionForm.color}
|
||||
onChange={(e) => setEmotionForm((prev) => ({ ...prev, color: e.target.value }))}
|
||||
style={{ width: '100%', height: 44, borderRadius: 10, border: '1px solid #e5e7eb', background: 'white' }}
|
||||
/>
|
||||
</Field>
|
||||
<YStack space="$2">
|
||||
{emotions.map((em) => (
|
||||
<ListItem
|
||||
key={`emo-${em.id}`}
|
||||
title={
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Tag label={em.name ?? ''} color={em.color ?? '#e5e7eb'} />
|
||||
</XStack>
|
||||
}
|
||||
iconAfter={
|
||||
<XStack space="$2">
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setEditingEmotion(em);
|
||||
setEmotionForm({ name: em.name ?? '', color: em.color ?? '#e5e7eb' });
|
||||
}}
|
||||
>
|
||||
<Pencil size={14} color="#007AFF" />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => removeEmotion(em.id)}>
|
||||
<Trash2 size={14} color="#ef4444" />
|
||||
</Pressable>
|
||||
</XStack>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</YStack>
|
||||
</YStack>
|
||||
</MobileSheet>
|
||||
|
||||
<Pressable
|
||||
onPress={() => setShowTaskSheet(true)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: 20,
|
||||
bottom: 90,
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: '#007AFF',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
boxShadow: '0 10px 25px rgba(0,122,255,0.35)',
|
||||
zIndex: 60,
|
||||
}}
|
||||
>
|
||||
<Plus size={20} color="#ffffff" />
|
||||
</Pressable>
|
||||
</MobileScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<YStack space="$1">
|
||||
<Text fontSize={12.5} fontWeight="600" color="#111827">
|
||||
{label}
|
||||
</Text>
|
||||
{children}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({ label, onPress, active, color }: { label: string; onPress: () => void; active: boolean; color?: string }) {
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
<XStack
|
||||
alignItems="center"
|
||||
paddingHorizontal="$3"
|
||||
paddingVertical={8}
|
||||
borderRadius={999}
|
||||
backgroundColor={active ? '#e0f2fe' : '#f3f4f6'}
|
||||
borderWidth={1}
|
||||
borderColor={active ? '#93c5fd' : '#e5e7eb'}
|
||||
>
|
||||
<Text fontSize={12} fontWeight="600" color={color ?? (active ? '#0f172a' : '#4b5563')}>
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
197
resources/js/admin/mobile/EventsPage.tsx
Normal file
197
resources/js/admin/mobile/EventsPage.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { CalendarDays, MapPin, Plus, Search } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MobileScaffold } from './components/Scaffold';
|
||||
import { MobileCard, PillBadge, CTAButton } from './components/Primitives';
|
||||
import { BottomNav } from './components/BottomNav';
|
||||
import { getEvents, TenantEvent } from '../api';
|
||||
import { adminPath } from '../constants';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { useMobileNav } from './hooks/useMobileNav';
|
||||
|
||||
export default function MobileEventsPage() {
|
||||
const { t } = useTranslation('management');
|
||||
const navigate = useNavigate();
|
||||
const [events, setEvents] = React.useState<TenantEvent[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const { go } = useMobileNav();
|
||||
const [query, setQuery] = React.useState('');
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
setEvents(await getEvents());
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<MobileScaffold
|
||||
title={t('events.list.dashboardTitle', 'All Events Dashboard')}
|
||||
onBack={() => navigate(-1)}
|
||||
rightSlot={
|
||||
<Pressable>
|
||||
<Search size={18} color="#0f172a" />
|
||||
</Pressable>
|
||||
}
|
||||
footer={
|
||||
<BottomNav active="events" onNavigate={go} />
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color="#b91c1c">
|
||||
{error}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
<CTAButton label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/mobile/events/new'))} />
|
||||
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t('events.list.search', 'Search events')}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 38,
|
||||
borderRadius: 10,
|
||||
border: '1px solid #e5e7eb',
|
||||
padding: '0 12px',
|
||||
fontSize: 13,
|
||||
background: 'white',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<YStack space="$2">
|
||||
{Array.from({ length: 3 }).map((_, idx) => (
|
||||
<MobileCard key={`sk-${idx}`} height={90} opacity={0.6} />
|
||||
))}
|
||||
</YStack>
|
||||
) : events.length === 0 ? (
|
||||
<MobileCard alignItems="center" justifyContent="center" space="$3">
|
||||
<Text fontSize="$md" fontWeight="700">
|
||||
{t('events.list.empty.title', 'Noch kein Event angelegt')}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color="#4b5563" textAlign="center">
|
||||
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
|
||||
</Text>
|
||||
<CTAButton label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/events/new'))} />
|
||||
</MobileCard>
|
||||
) : (
|
||||
<YStack space="$3">
|
||||
{events
|
||||
.filter((event) => {
|
||||
if (!query.trim()) return true;
|
||||
const hay = `${event.name ?? ''} ${event.location ?? ''}`.toLowerCase();
|
||||
return hay.includes(query.toLowerCase());
|
||||
})
|
||||
.map((event) => (
|
||||
<EventRow
|
||||
key={event.id}
|
||||
event={event}
|
||||
onOpen={(slug) => navigate(adminPath(`/mobile/events/${slug}`))}
|
||||
onEdit={(slug) => navigate(adminPath(`/mobile/events/${slug}/edit`))}
|
||||
/>
|
||||
))}
|
||||
</YStack>
|
||||
)}
|
||||
</MobileScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
function EventRow({ event, onOpen, onEdit }: { event: TenantEvent; onOpen: (slug: string) => void; onEdit: (slug: string) => void }) {
|
||||
const status = resolveStatus(event);
|
||||
return (
|
||||
<MobileCard borderColor="#e2e8f0">
|
||||
<XStack justifyContent="space-between" alignItems="flex-start" space="$2">
|
||||
<YStack space="$1">
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
{renderName(event.name)}
|
||||
</Text>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<CalendarDays size={14} color="#6b7280" />
|
||||
<Text fontSize="$sm" color="#4b5563">
|
||||
{formatDate(event.event_date)}
|
||||
</Text>
|
||||
</XStack>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<MapPin size={14} color="#6b7280" />
|
||||
<Text fontSize="$sm" color="#4b5563">
|
||||
{resolveLocation(event)}
|
||||
</Text>
|
||||
</XStack>
|
||||
<PillBadge tone={status.tone}>{status.label}</PillBadge>
|
||||
</YStack>
|
||||
<Pressable onPress={() => onEdit(event.slug)}>
|
||||
<Text fontSize="$xl" color="#9ca3af">
|
||||
˅
|
||||
</Text>
|
||||
</Pressable>
|
||||
</XStack>
|
||||
|
||||
<Pressable onPress={() => onOpen(event.slug)} style={{ marginTop: 8 }}>
|
||||
<XStack alignItems="center" justifyContent="flex-start" space="$2">
|
||||
<Plus size={16} color="#007AFF" />
|
||||
<Text fontSize="$sm" color="#007AFF" fontWeight="700">
|
||||
Open event
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
</MobileCard>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveStatus(event: TenantEvent): { label: string; tone: 'success' | 'warning' | 'muted' } {
|
||||
if (event.status === 'published') {
|
||||
return { label: 'Upcoming', tone: 'success' };
|
||||
}
|
||||
if (event.status === 'draft') {
|
||||
return { label: 'Draft', tone: 'warning' };
|
||||
}
|
||||
return { label: 'Past', tone: 'muted' };
|
||||
}
|
||||
|
||||
function renderName(name: TenantEvent['name']): string {
|
||||
if (typeof name === 'string') return name;
|
||||
if (name && typeof name === 'object') {
|
||||
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Unbenanntes Event';
|
||||
}
|
||||
return 'Unbenanntes Event';
|
||||
}
|
||||
|
||||
function resolveLocation(event: TenantEvent): string {
|
||||
const settings = (event.settings ?? {}) as Record<string, unknown>;
|
||||
const candidate =
|
||||
(settings.location as string | undefined) ??
|
||||
(settings.address as string | undefined) ??
|
||||
(settings.city as string | undefined);
|
||||
if (candidate && candidate.trim()) {
|
||||
return candidate;
|
||||
}
|
||||
return 'Location';
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return 'Date tbd';
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 'Date tbd';
|
||||
}
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
191
resources/js/admin/mobile/LoginPage.tsx
Normal file
191
resources/js/admin/mobile/LoginPage.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, Lock, Mail } from 'lucide-react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { adminPath, ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_EVENTS_PATH } from '../constants';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { resolveReturnTarget } from '../lib/returnTo';
|
||||
|
||||
type LoginResponse = {
|
||||
token: string;
|
||||
token_type: string;
|
||||
abilities: string[];
|
||||
};
|
||||
|
||||
async function performLogin(payload: { login: string; password: string; return_to?: string | null }): Promise<LoginResponse> {
|
||||
const response = await fetch('/api/v1/tenant-auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
remember: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.status === 422) {
|
||||
const data = await response.json();
|
||||
const errors = data.errors ?? {};
|
||||
const flattened = Object.values(errors).flat();
|
||||
throw new Error(flattened.join(' ') || 'Validation failed');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Login failed.');
|
||||
}
|
||||
|
||||
return (await response.json()) as LoginResponse;
|
||||
}
|
||||
|
||||
export default function MobileLoginPage() {
|
||||
const { status, applyToken, abilities } = useAuth();
|
||||
const { t } = useTranslation('auth');
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
|
||||
const rawReturnTo = searchParams.get('return_to');
|
||||
|
||||
const computeDefaultAfterLogin = React.useCallback(
|
||||
(abilityList?: string[]) => {
|
||||
const source = abilityList ?? abilities;
|
||||
return source.includes('tenant-admin') ? ADMIN_DEFAULT_AFTER_LOGIN_PATH : ADMIN_EVENTS_PATH;
|
||||
},
|
||||
[abilities],
|
||||
);
|
||||
|
||||
const fallbackTarget = computeDefaultAfterLogin();
|
||||
const { finalTarget } = React.useMemo(
|
||||
() => resolveReturnTarget(rawReturnTo, fallbackTarget),
|
||||
[rawReturnTo, fallbackTarget],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (status === 'authenticated') {
|
||||
navigate(finalTarget, { replace: true });
|
||||
}
|
||||
}, [finalTarget, navigate, status]);
|
||||
|
||||
const [login, setLogin] = React.useState('');
|
||||
const [password, setPassword] = React.useState('');
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationKey: ['tenantAdminLoginMobile'],
|
||||
mutationFn: performLogin,
|
||||
onError: (err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
setError(null);
|
||||
await applyToken(data.token, data.abilities ?? []);
|
||||
const postLoginFallback = computeDefaultAfterLogin(data.abilities ?? []);
|
||||
const { finalTarget: successTarget } = resolveReturnTarget(rawReturnTo, postLoginFallback);
|
||||
navigate(successTarget, { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
const isSubmitting =
|
||||
(mutation as { isPending?: boolean; isLoading?: boolean }).isPending ??
|
||||
(mutation as { isPending?: boolean; isLoading?: boolean }).isLoading ??
|
||||
false;
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate({
|
||||
login,
|
||||
password,
|
||||
return_to: rawReturnTo,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-[#0b1020] via-[#0f172a] to-[#0b1020] px-5 py-10 text-white">
|
||||
<div className="w-full max-w-md space-y-8 rounded-3xl border border-white/10 bg-white/5 p-8 shadow-2xl shadow-blue-500/10 backdrop-blur-lg">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/10 ring-1 ring-white/15">
|
||||
<img src="/logo-transparent-md.png" alt="Fotospiel" className="h-10 w-10" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('login.panel_title', 'Team Login')}</h1>
|
||||
<p className="text-sm text-white/70">
|
||||
{t('login.panel_copy', 'Melde dich an, um Events, Fotos und Aufgaben zu verwalten.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-white/90" htmlFor="login-mobile">
|
||||
{t('login.username_or_email', 'E-Mail oder Benutzername')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-white/10 bg-white/10 px-3 py-3">
|
||||
<Mail size={16} className="text-white/70" />
|
||||
<input
|
||||
id="login-mobile"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={login}
|
||||
onChange={(e) => setLogin(e.target.value)}
|
||||
placeholder={t('login.username_or_email_placeholder', 'name@example.com')}
|
||||
className="w-full bg-transparent text-sm text-white placeholder:text-white/50 focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-white/90" htmlFor="password-mobile">
|
||||
{t('login.password', 'Passwort')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-white/10 bg-white/10 px-3 py-3">
|
||||
<Lock size={16} className="text-white/70" />
|
||||
<input
|
||||
id="password-mobile"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('login.password_placeholder', '••••••••')}
|
||||
className="w-full bg-transparent text-sm text-white placeholder:text-white/50 focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-500/40 bg-rose-500/10 px-3 py-2 text-sm text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-2xl bg-gradient-to-r from-[#2563eb] via-[#3b82f6] to-[#22d3ee] text-sm font-semibold text-white shadow-lg shadow-blue-500/25 transition hover:brightness-110 disabled:opacity-70"
|
||||
>
|
||||
<Loader2 className={`h-4 w-4 animate-spin ${isSubmitting ? 'opacity-100' : 'opacity-0'}`} />
|
||||
<span>{isSubmitting ? t('login.loading', 'Anmeldung …') : t('login.submit', 'Anmelden')}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="text-center text-xs text-white/60">
|
||||
{t('login.support', 'Fragen? Schreib uns an support@fotospiel.de oder antworte direkt auf deine Einladung.')}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(adminPath('/faq'))}
|
||||
className="text-xs font-semibold text-white/70 underline underline-offset-4"
|
||||
>
|
||||
{t('login.faq', 'Hilfe & FAQ')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
resources/js/admin/mobile/ProfilePage.tsx
Normal file
132
resources/js/admin/mobile/ProfilePage.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LogOut, User, Settings, Shield, Globe, Moon } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { MobileScaffold } from './components/Scaffold';
|
||||
import { MobileCard, CTAButton } from './components/Primitives';
|
||||
import { BottomNav } from './components/BottomNav';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { fetchTenantProfile } from '../api';
|
||||
import { useMobileNav } from './hooks/useMobileNav';
|
||||
import { adminPath } from '../constants';
|
||||
import i18n from '../i18n';
|
||||
|
||||
export default function MobileProfilePage() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
const { go } = useMobileNav();
|
||||
|
||||
const [name, setName] = React.useState(user?.name ?? 'Guest');
|
||||
const [email, setEmail] = React.useState(user?.email ?? '');
|
||||
const [role, setRole] = React.useState<string>(user?.role ?? '');
|
||||
const [theme, setTheme] = React.useState<'light' | 'dark'>('light');
|
||||
const [language, setLanguage] = React.useState<string>(i18n.language || 'de');
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const profile = await fetchTenantProfile();
|
||||
setName(profile.name ?? name);
|
||||
setEmail(profile.email ?? email);
|
||||
setRole((profile as any)?.role ?? role);
|
||||
} catch {
|
||||
// non-fatal for mobile profile view
|
||||
}
|
||||
})();
|
||||
}, [email, name, role]);
|
||||
|
||||
return (
|
||||
<MobileScaffold
|
||||
title={t('profile.title', 'Profile')}
|
||||
onBack={() => navigate(-1)}
|
||||
footer={
|
||||
<BottomNav active="profile" onNavigate={go} />
|
||||
}
|
||||
>
|
||||
<MobileCard space="$3" alignItems="center">
|
||||
<XStack
|
||||
width={64}
|
||||
height={64}
|
||||
borderRadius={20}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor="#e0f2fe"
|
||||
>
|
||||
<User size={28} color="#2563eb" />
|
||||
</XStack>
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
{name}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color="#4b5563">
|
||||
{email}
|
||||
</Text>
|
||||
{role ? (
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
{role}
|
||||
</Text>
|
||||
) : null}
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
{t('profile.settings', 'Settings')}
|
||||
</Text>
|
||||
<Pressable onPress={() => navigate(adminPath('/settings'))}>
|
||||
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2" borderBottomWidth={1} borderColor="#e5e7eb">
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{t('profile.account', 'Account & Security')}
|
||||
</Text>
|
||||
<Settings size={18} color="#9ca3af" />
|
||||
</XStack>
|
||||
</Pressable>
|
||||
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2" borderBottomWidth={1} borderColor="#e5e7eb">
|
||||
<XStack space="$2" alignItems="center">
|
||||
<Globe size={16} color="#6b7280" />
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{t('profile.language', 'Language')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => {
|
||||
const lng = e.target.value;
|
||||
setLanguage(lng);
|
||||
void i18n.changeLanguage(lng);
|
||||
}}
|
||||
style={{ border: '1px solid #e5e7eb', borderRadius: 10, padding: '6px 10px', background: 'white', fontSize: 13 }}
|
||||
>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</XStack>
|
||||
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
|
||||
<XStack space="$2" alignItems="center">
|
||||
<Moon size={16} color="#6b7280" />
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{t('profile.theme', 'Theme')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => setTheme(e.target.value as 'light' | 'dark')}
|
||||
style={{ border: '1px solid #e5e7eb', borderRadius: 10, padding: '6px 10px', background: 'white', fontSize: 13 }}
|
||||
>
|
||||
<option value="light">{t('profile.themeLight', 'Light')}</option>
|
||||
<option value="dark">{t('profile.themeDark', 'Dark')}</option>
|
||||
</select>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
|
||||
<CTAButton
|
||||
label={t('profile.logout', 'Log out')}
|
||||
onPress={() => {
|
||||
logout();
|
||||
navigate(adminPath('/logout'));
|
||||
}}
|
||||
/>
|
||||
</MobileScaffold>
|
||||
);
|
||||
}
|
||||
270
resources/js/admin/mobile/QrPrintPage.tsx
Normal file
270
resources/js/admin/mobile/QrPrintPage.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Download, Share2, ChevronRight, RefreshCcw } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { MobileScaffold } from './components/Scaffold';
|
||||
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
||||
import { BottomNav } from './components/BottomNav';
|
||||
import { TenantEvent, getEvent, getEventQrInvites, createQrInvite } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useMobileNav } from './hooks/useMobileNav';
|
||||
import { MobileSheet } from './components/Sheet';
|
||||
|
||||
const LAYOUTS = [
|
||||
{ key: 'badges', title: 'Badges', subtitle: 'Standard, Staff' },
|
||||
{ key: 'tents', title: 'Table Tents', subtitle: 'A4, Letter' },
|
||||
{ key: 'posters', title: 'Posters', subtitle: 'A3, 11x17' },
|
||||
{ key: 'programs', title: 'Event Programs', subtitle: 'Folded, Booklet' },
|
||||
];
|
||||
|
||||
export default function MobileQrPrintPage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const slug = slugParam ?? null;
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [paperSize, setPaperSize] = React.useState('A4 (210 x 297 mm)');
|
||||
const [qrUrl, setQrUrl] = React.useState<string>('');
|
||||
const { go } = useMobileNav(slug);
|
||||
const [showPaperSheet, setShowPaperSheet] = React.useState(false);
|
||||
const [showLayoutSheet, setShowLayoutSheet] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!slug) return;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getEvent(slug);
|
||||
const invites = await getEventQrInvites(slug);
|
||||
setEvent(data);
|
||||
const primaryInvite = invites.find((item) => item.is_active) ?? invites[0];
|
||||
setQrUrl(primaryInvite?.url ?? data.public_url ?? '');
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'QR-Daten konnten nicht geladen werden.')));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [slug, t]);
|
||||
|
||||
return (
|
||||
<MobileScaffold
|
||||
title={t('events.qr.title', 'QR Code & Print Layouts')}
|
||||
onBack={() => navigate(-1)}
|
||||
rightSlot={
|
||||
<Pressable onPress={() => window.location.reload()}>
|
||||
<RefreshCcw size={18} color="#0f172a" />
|
||||
</Pressable>
|
||||
}
|
||||
footer={
|
||||
<BottomNav active="events" onNavigate={go} />
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color="#b91c1c">
|
||||
{error}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
<MobileCard space="$3" alignItems="center">
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
{t('events.qr.heroTitle', 'Entrance QR Code')}
|
||||
</Text>
|
||||
<YStack
|
||||
width={180}
|
||||
height={180}
|
||||
borderRadius={16}
|
||||
borderWidth={1}
|
||||
borderColor="#e5e7eb"
|
||||
backgroundColor="#f8fafc"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
overflow="hidden"
|
||||
>
|
||||
{qrUrl ? (
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${encodeURIComponent(qrUrl)}`}
|
||||
alt="QR"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<Text color="#9ca3af" fontSize="$sm">
|
||||
{t('events.qr.missing', 'Kein QR-Link vorhanden')}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
{t('events.qr.description', 'Scan to access the event guest app.')}
|
||||
</Text>
|
||||
<XStack space="$2" width="100%" marginTop="$2">
|
||||
<CTAButton
|
||||
label={t('events.qr.download', 'Download')}
|
||||
onPress={() => {
|
||||
if (qrUrl) {
|
||||
toast.success(t('events.qr.downloadStarted', 'Download gestartet'));
|
||||
} else {
|
||||
toast.error(t('events.qr.missing', 'Kein QR-Link vorhanden'));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CTAButton
|
||||
label={t('events.qr.share', 'Share')}
|
||||
onPress={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(qrUrl || event?.public_url || '');
|
||||
toast.success(t('events.qr.shareSuccess', 'Link kopiert'));
|
||||
} catch {
|
||||
toast.error(t('events.qr.shareFailed', 'Konnte Link nicht kopieren'));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$2">
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
{t('events.qr.layouts', 'Print Layouts')}
|
||||
</Text>
|
||||
<YStack space="$1">
|
||||
{LAYOUTS.map((layout) => (
|
||||
<XStack
|
||||
key={layout.key}
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
paddingVertical="$2"
|
||||
borderBottomWidth={layout.key === 'programs' ? 0 : 1}
|
||||
borderColor="#e5e7eb"
|
||||
onPress={() => setShowLayoutSheet(true)}
|
||||
>
|
||||
<YStack>
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
{layout.title}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
{layout.subtitle}
|
||||
</Text>
|
||||
</YStack>
|
||||
<ChevronRight size={16} color="#9ca3af" />
|
||||
</XStack>
|
||||
))}
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
{t('events.qr.templates', 'Templates')}
|
||||
</Text>
|
||||
<XStack justifyContent="space-between" alignItems="center" paddingVertical="$2" borderBottomWidth={1} borderColor="#e5e7eb">
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{t('events.qr.branding', 'Branding')}
|
||||
</Text>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input type="checkbox" defaultChecked />
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{t('common.enabled', 'Enabled')}
|
||||
</Text>
|
||||
</label>
|
||||
</XStack>
|
||||
<Pressable onPress={() => setShowPaperSheet(true)}>
|
||||
<XStack justifyContent="space-between" alignItems="center" paddingVertical="$2">
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{t('events.qr.paper', 'Paper Size')}
|
||||
</Text>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{paperSize}
|
||||
</Text>
|
||||
<ChevronRight size={16} color="#9ca3af" />
|
||||
</XStack>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
<CTAButton
|
||||
label={t('events.qr.preview', 'Preview & Print')}
|
||||
onPress={() => toast.success(t('events.qr.previewStarted', 'Preview gestartet (mock)'))}
|
||||
/>
|
||||
<CTAButton
|
||||
label={t('events.qr.createLink', 'Neuen QR-Link erstellen')}
|
||||
onPress={async () => {
|
||||
if (!slug) return;
|
||||
try {
|
||||
const invite = await createQrInvite(slug, { label: 'Mobile Link' });
|
||||
setQrUrl(invite.url);
|
||||
toast.success(t('events.qr.created', 'Neuer QR-Link erstellt'));
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('events.qr.createFailed', 'Link konnte nicht erstellt werden.')));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</MobileCard>
|
||||
|
||||
<MobileSheet
|
||||
open={showPaperSheet}
|
||||
onClose={() => setShowPaperSheet(false)}
|
||||
title={t('events.qr.paper', 'Paper Size')}
|
||||
footer={null}
|
||||
>
|
||||
<YStack space="$2">
|
||||
{['A4 (210 x 297 mm)', 'Letter (8.5 x 11 in)', 'A3 (297 x 420 mm)'].map((size) => (
|
||||
<Pressable
|
||||
key={size}
|
||||
onPress={() => {
|
||||
setPaperSize(size);
|
||||
setShowPaperSheet(false);
|
||||
}}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
{size}
|
||||
</Text>
|
||||
{paperSize === size ? <ChevronRight size={16} color="#007AFF" /> : null}
|
||||
</XStack>
|
||||
</Pressable>
|
||||
))}
|
||||
</YStack>
|
||||
</MobileSheet>
|
||||
|
||||
<MobileSheet
|
||||
open={showLayoutSheet}
|
||||
onClose={() => setShowLayoutSheet(false)}
|
||||
title={t('events.qr.layouts', 'Print Layouts')}
|
||||
footer={
|
||||
<CTAButton
|
||||
label={t('events.qr.preview', 'Preview & Print')}
|
||||
onPress={() => toast.success(t('events.qr.previewStarted', 'Preview gestartet (mock)'))}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<YStack space="$2">
|
||||
{LAYOUTS.map((layout) => (
|
||||
<MobileCard key={`lay-${layout.key}`} padding="$3" borderColor="#e5e7eb">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack>
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
{layout.title}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
{layout.subtitle}
|
||||
</Text>
|
||||
</YStack>
|
||||
<PillBadge tone="muted">{paperSize}</PillBadge>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
))}
|
||||
</YStack>
|
||||
</MobileSheet>
|
||||
</MobileScaffold>
|
||||
);
|
||||
}
|
||||
78
resources/js/admin/mobile/components/BottomNav.tsx
Normal file
78
resources/js/admin/mobile/components/BottomNav.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { Home, CheckSquare, Bell, User } from 'lucide-react';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAlertsBadge } from '../hooks/useAlertsBadge';
|
||||
|
||||
export type NavKey = 'events' | 'tasks' | 'alerts' | 'profile';
|
||||
|
||||
export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate: (key: NavKey) => void }) {
|
||||
const { t } = useTranslation('mobile');
|
||||
const theme = useTheme();
|
||||
const { count: alertCount } = useAlertsBadge();
|
||||
const items: Array<{ key: NavKey; icon: React.ComponentType<{ size?: number; color?: string }>; label: string }> = [
|
||||
{ key: 'events', icon: Home, label: t('nav.events', 'Events') },
|
||||
{ key: 'tasks', icon: CheckSquare, label: t('nav.tasks', 'Tasks') },
|
||||
{ key: 'alerts', icon: Bell, label: t('nav.alerts', 'Alerts') },
|
||||
{ key: 'profile', icon: User, label: t('nav.profile', 'Profile') },
|
||||
];
|
||||
|
||||
return (
|
||||
<YStack
|
||||
position="fixed"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
backgroundColor="white"
|
||||
borderTopWidth={1}
|
||||
borderColor="#e5e7eb"
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$4"
|
||||
zIndex={50}
|
||||
shadowColor="#0f172a"
|
||||
shadowOpacity={0.08}
|
||||
shadowRadius={12}
|
||||
shadowOffset={{ width: 0, height: -4 }}
|
||||
// allow for safe-area inset on modern phones
|
||||
style={{ paddingBottom: 'max(env(safe-area-inset-bottom, 0px), 8px)' }}
|
||||
>
|
||||
<XStack justifyContent="space-between" alignItems="center">
|
||||
{items.map((item) => {
|
||||
const activeState = item.key === active;
|
||||
const IconCmp = item.icon;
|
||||
return (
|
||||
<Pressable key={item.key} onPress={() => onNavigate(item.key)}>
|
||||
<YStack alignItems="center" space="$1" position="relative">
|
||||
<IconCmp size={20} color={activeState ? String(theme.primary?.val ?? '#007AFF') : '#94a3b8'} />
|
||||
<Text fontSize="$xs" color={activeState ? '$primary' : '#6b7280'}>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.key === 'alerts' && alertCount > 0 ? (
|
||||
<XStack
|
||||
position="absolute"
|
||||
top={-6}
|
||||
right={-12}
|
||||
minWidth={18}
|
||||
height={18}
|
||||
paddingHorizontal={6}
|
||||
borderRadius={999}
|
||||
backgroundColor="#ef4444"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text fontSize={10} color="white" fontWeight="700">
|
||||
{alertCount > 9 ? '9+' : alertCount}
|
||||
</Text>
|
||||
</XStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
144
resources/js/admin/mobile/components/Primitives.tsx
Normal file
144
resources/js/admin/mobile/components/Primitives.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import React from 'react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
|
||||
export function MobileCard({ children, ...rest }: React.ComponentProps<typeof YStack>) {
|
||||
return (
|
||||
<YStack
|
||||
backgroundColor="white"
|
||||
borderRadius={16}
|
||||
borderWidth={1}
|
||||
borderColor="#e5e7eb"
|
||||
shadowColor="#0f172a"
|
||||
shadowOpacity={0.06}
|
||||
shadowRadius={12}
|
||||
shadowOffset={{ width: 0, height: 8 }}
|
||||
padding="$3.5"
|
||||
space="$2"
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
export function PillBadge({
|
||||
tone = 'muted',
|
||||
children,
|
||||
}: {
|
||||
tone?: 'success' | 'warning' | 'muted';
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const palette: Record<typeof tone, { bg: string; text: string; border: string }> = {
|
||||
success: { bg: '#ecfdf3', text: '#047857', border: '#bbf7d0' },
|
||||
warning: { bg: '#fffbeb', text: '#92400e', border: '#fef3c7' },
|
||||
muted: { bg: '#f3f4f6', text: '#374151', border: '#e5e7eb' },
|
||||
};
|
||||
const colors = palette[tone] ?? palette.muted;
|
||||
return (
|
||||
<XStack
|
||||
alignItems="center"
|
||||
paddingHorizontal="$3"
|
||||
paddingVertical="$1.5"
|
||||
borderRadius={999}
|
||||
borderWidth={1}
|
||||
backgroundColor={colors.bg}
|
||||
borderColor={colors.border}
|
||||
>
|
||||
<Text fontSize="$xs" fontWeight="700" color={colors.text}>
|
||||
{children}
|
||||
</Text>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
export function CTAButton({
|
||||
label,
|
||||
onPress,
|
||||
tone = 'primary',
|
||||
}: {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
tone?: 'primary' | 'ghost';
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const isPrimary = tone === 'primary';
|
||||
return (
|
||||
<Pressable onPress={onPress} style={{ width: '100%' }}>
|
||||
<XStack
|
||||
height={56}
|
||||
borderRadius={14}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor={isPrimary ? String(theme.primary?.val ?? '#007AFF') : 'white'}
|
||||
borderWidth={isPrimary ? 0 : 1}
|
||||
borderColor={isPrimary ? 'transparent' : '#e5e7eb'}
|
||||
>
|
||||
<Text fontSize="$sm" fontWeight="800" color={isPrimary ? 'white' : '#111827'}>
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function KpiTile({
|
||||
icon: IconCmp,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ComponentType<{ size?: number; color?: string }>;
|
||||
label: string;
|
||||
value: string | number;
|
||||
}) {
|
||||
return (
|
||||
<MobileCard borderRadius={14} padding="$3" width="32%" minWidth={110} alignItems="flex-start">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<XStack width={32} height={32} borderRadius={12} backgroundColor="#e5f0ff" alignItems="center" justifyContent="center">
|
||||
<IconCmp size={16} color="#2563eb" />
|
||||
</XStack>
|
||||
<Text fontSize="$xs" color="#111827">
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$xl" fontWeight="800" color="#111827">
|
||||
{value}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActionTile({
|
||||
icon: IconCmp,
|
||||
label,
|
||||
color,
|
||||
onPress,
|
||||
}: {
|
||||
icon: React.ComponentType<{ size?: number; color?: string }>;
|
||||
label: string;
|
||||
color: string;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable onPress={onPress} style={{ width: '48%' }}>
|
||||
<YStack
|
||||
borderRadius={16}
|
||||
padding="$3"
|
||||
space="$2"
|
||||
backgroundColor={`${color}22`}
|
||||
borderWidth={1}
|
||||
borderColor={`${color}55`}
|
||||
minHeight={110}
|
||||
>
|
||||
<XStack width={38} height={38} borderRadius={12} backgroundColor={color} alignItems="center" justifyContent="center">
|
||||
<IconCmp size={18} color="white" />
|
||||
</XStack>
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
{label}
|
||||
</Text>
|
||||
</YStack>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
59
resources/js/admin/mobile/components/Scaffold.tsx
Normal file
59
resources/js/admin/mobile/components/Scaffold.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type MobileScaffoldProps = {
|
||||
title: string;
|
||||
onBack?: () => void;
|
||||
rightSlot?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function MobileScaffold({ title, onBack, rightSlot, children, footer }: MobileScaffoldProps) {
|
||||
const { t } = useTranslation('mobile');
|
||||
return (
|
||||
<YStack backgroundColor="#f7f8fb" minHeight="100vh">
|
||||
<XStack
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
paddingHorizontal="$4"
|
||||
paddingTop="$4"
|
||||
paddingBottom="$3"
|
||||
backgroundColor="white"
|
||||
borderBottomWidth={1}
|
||||
borderColor="#e5e7eb"
|
||||
>
|
||||
<XStack alignItems="center" space="$2">
|
||||
{onBack ? (
|
||||
<Pressable onPress={onBack}>
|
||||
<XStack alignItems="center" space="$1.5">
|
||||
<ChevronLeft size={18} color="#007AFF" />
|
||||
<Text fontSize="$sm" color="#007AFF" fontWeight="600">
|
||||
{t('actions.back', 'Back')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
) : (
|
||||
<Text />
|
||||
)}
|
||||
</XStack>
|
||||
<Text fontSize="$lg" fontWeight="800" color="#111827">
|
||||
{title}
|
||||
</Text>
|
||||
<XStack minWidth={40} justifyContent="flex-end">
|
||||
{rightSlot ?? null}
|
||||
</XStack>
|
||||
</XStack>
|
||||
|
||||
<YStack flex={1} padding="$4" space="$3" paddingBottom={footer ? '$14' : '$5'}>
|
||||
{children}
|
||||
</YStack>
|
||||
|
||||
{footer ? <YStack>{footer}</YStack> : null}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
55
resources/js/admin/mobile/components/Sheet.tsx
Normal file
55
resources/js/admin/mobile/components/Sheet.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type SheetProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
/** Optional bottom offset so content sits above the bottom nav/safe-area. */
|
||||
bottomOffsetPx?: number;
|
||||
};
|
||||
|
||||
export function MobileSheet({ open, title, onClose, children, footer, bottomOffsetPx = 88 }: SheetProps) {
|
||||
const { t } = useTranslation('mobile');
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm">
|
||||
<YStack
|
||||
width="100%"
|
||||
maxWidth={520}
|
||||
borderTopLeftRadius={24}
|
||||
borderTopRightRadius={24}
|
||||
backgroundColor="white"
|
||||
padding="$4"
|
||||
paddingBottom="$7"
|
||||
space="$3"
|
||||
shadowColor="#0f172a"
|
||||
shadowOpacity={0.12}
|
||||
shadowRadius={18}
|
||||
shadowOffset={{ width: 0, height: -8 }}
|
||||
maxHeight="82vh"
|
||||
overflow="auto"
|
||||
// keep sheet above bottom nav / safe area
|
||||
style={{ marginBottom: `max(env(safe-area-inset-bottom, 0px), ${bottomOffsetPx}px)` }}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
{title}
|
||||
</Text>
|
||||
<Pressable onPress={onClose}>
|
||||
<Text fontSize="$md" color="#6b7280">
|
||||
{t('actions.close', 'Close')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</XStack>
|
||||
{children}
|
||||
{footer ? footer : null}
|
||||
</YStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
resources/js/admin/mobile/components/Tag.tsx
Normal file
22
resources/js/admin/mobile/components/Tag.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { XStack } from '@tamagui/stacks';
|
||||
|
||||
export function Tag({ label, color = '#e5e7eb' }: { label: string; color?: string }) {
|
||||
return (
|
||||
<XStack
|
||||
alignItems="center"
|
||||
paddingHorizontal="$2"
|
||||
paddingVertical={2}
|
||||
borderRadius={999}
|
||||
backgroundColor={`${color}22`}
|
||||
borderWidth={1}
|
||||
borderColor={`${color}55`}
|
||||
alignSelf="flex-start"
|
||||
>
|
||||
<Text fontSize={11} fontWeight="600" color="#111827">
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
28
resources/js/admin/mobile/hooks/useAlertsBadge.ts
Normal file
28
resources/js/admin/mobile/hooks/useAlertsBadge.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEventContext } from '../../context/EventContext';
|
||||
import { listGuestNotifications } from '../../api';
|
||||
|
||||
/**
|
||||
* Lightweight badge count for alerts tab.
|
||||
* Fetches guest notifications for the active event and returns count.
|
||||
*/
|
||||
export function useAlertsBadge() {
|
||||
const { activeEvent } = useEventContext();
|
||||
const slug = activeEvent?.slug;
|
||||
|
||||
const { data: count = 0 } = useQuery<number>({
|
||||
queryKey: ['mobile', 'alerts', 'badge', slug],
|
||||
enabled: Boolean(slug),
|
||||
staleTime: 60_000,
|
||||
queryFn: async () => {
|
||||
if (!slug) {
|
||||
return 0;
|
||||
}
|
||||
const alerts = await listGuestNotifications(slug);
|
||||
return Array.isArray(alerts) ? alerts.length : 0;
|
||||
},
|
||||
});
|
||||
|
||||
return React.useMemo(() => ({ count }), [count]);
|
||||
}
|
||||
38
resources/js/admin/mobile/hooks/useMobileNav.ts
Normal file
38
resources/js/admin/mobile/hooks/useMobileNav.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { adminPath } from '../../constants';
|
||||
import { useEventContext } from '../../context/EventContext';
|
||||
import { NavKey } from '../components/BottomNav';
|
||||
|
||||
export function useMobileNav(currentSlug?: string | null) {
|
||||
const navigate = useNavigate();
|
||||
const { activeEvent } = useEventContext();
|
||||
const slug = currentSlug ?? activeEvent?.slug ?? null;
|
||||
|
||||
const go = React.useCallback(
|
||||
(key: NavKey) => {
|
||||
if (key === 'events') {
|
||||
navigate(adminPath('/mobile/events'));
|
||||
return;
|
||||
}
|
||||
if (key === 'tasks') {
|
||||
if (slug) {
|
||||
navigate(adminPath(`/mobile/events/${slug}/tasks`));
|
||||
} else {
|
||||
navigate(adminPath('/mobile/events'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key === 'alerts') {
|
||||
navigate(adminPath('/mobile/alerts'));
|
||||
return;
|
||||
}
|
||||
if (key === 'profile') {
|
||||
navigate(adminPath('/mobile/profile'));
|
||||
}
|
||||
},
|
||||
[navigate, slug]
|
||||
);
|
||||
|
||||
return { go, slug };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,10 @@
|
||||
import React from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { AlertTriangle, ArrowRight, CalendarDays, Camera, Heart, Plus } from 'lucide-react';
|
||||
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FrostedSurface, SectionCard } from '../components/tenant';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AppCard, PrimaryCTA, Segmented, StatusPill, MetaRow, BottomNav } from '../tamagui/primitives';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { getEvents, TenantEvent } from '../api';
|
||||
@@ -86,33 +84,32 @@ export default function EventsPage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<AdminLayout title={pageTitle}>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Fehler beim Laden</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<AdminLayout title={pageTitle} disableCommandShelf>
|
||||
<YStack space="$3" maxWidth={560} marginHorizontal="auto" paddingBottom="$8">
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Fehler beim Laden</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<AppCard>
|
||||
<YStack space="$1">
|
||||
<Text fontSize="$lg" fontWeight="700" color="$color">
|
||||
{t('events.list.dashboardTitle', 'All Events Dashboard')}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color="$color">
|
||||
{t('events.list.dashboardSubtitle', 'Schneller Überblick über deine Events')}
|
||||
</Text>
|
||||
</YStack>
|
||||
<Segmented
|
||||
options={filterOptions.map((opt) => ({ key: opt.key, label: `${opt.label} (${opt.count})` }))}
|
||||
value={statusFilter}
|
||||
onChange={(key) => setStatusFilter(key as typeof statusFilter)}
|
||||
/>
|
||||
<PrimaryCTA label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/events/new'))} />
|
||||
</AppCard>
|
||||
|
||||
<SectionCard className="space-y-4">
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{filterOptions.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(option.key)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-full border px-4 py-1.5 text-xs font-semibold transition',
|
||||
statusFilter === option.key
|
||||
? 'border-rose-200 bg-rose-50 text-rose-700 shadow shadow-rose-100/40 dark:border-white/60 dark:bg-white/10 dark:text-white'
|
||||
: 'border-slate-200 text-slate-600 hover:text-slate-900 dark:border-white/15 dark:text-slate-300 dark:hover:text-white'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
<span className="text-[11px] text-slate-400 dark:text-slate-500">{option.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{loading ? (
|
||||
<LoadingState />
|
||||
) : filteredRows.length === 0 ? (
|
||||
@@ -125,7 +122,7 @@ export default function EventsPage() {
|
||||
onCreate={() => navigate(adminPath('/events/new'))}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<YStack space="$3">
|
||||
{filteredRows.map((event) => (
|
||||
<EventCard
|
||||
key={event.id}
|
||||
@@ -134,9 +131,21 @@ export default function EventsPage() {
|
||||
translateCommon={translateCommon}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</YStack>
|
||||
)}
|
||||
</SectionCard>
|
||||
</YStack>
|
||||
<BottomNav
|
||||
active="events"
|
||||
onNavigate={(key) => {
|
||||
if (key === 'analytics') {
|
||||
navigate(adminPath('/dashboard'));
|
||||
} else if (key === 'settings') {
|
||||
navigate(adminPath('/settings'));
|
||||
} else {
|
||||
navigate(adminPath('/events'));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@@ -158,6 +167,14 @@ function EventCard({
|
||||
() => buildLimitWarnings(event.limits ?? null, (key, opts) => translateCommon(`limits.${key}`, undefined, opts)),
|
||||
[event.limits, translateCommon],
|
||||
);
|
||||
const statusLabel = translateCommon(
|
||||
event.status === 'published'
|
||||
? 'events.status.published'
|
||||
: event.status === 'archived'
|
||||
? 'events.status.archived'
|
||||
: 'events.status.draft',
|
||||
event.status === 'published' ? 'Live' : event.status === 'archived' ? 'Archiviert' : 'Entwurf',
|
||||
);
|
||||
const metaItems = [
|
||||
{
|
||||
key: 'date',
|
||||
@@ -189,68 +206,71 @@ function EventCard({
|
||||
];
|
||||
|
||||
return (
|
||||
<FrostedSurface className="space-y-4 rounded-3xl p-5 shadow-lg shadow-rose-100/30 transition hover:-translate-y-0.5 hover:shadow-rose-200/60">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-rose-300/80">{translate('events.list.item.label', 'Event')}</p>
|
||||
<h3 className="text-xl font-semibold text-slate-900">{renderName(event.name)}</h3>
|
||||
</div>
|
||||
<Badge className={isPublished ? 'bg-emerald-500/90 text-white shadow shadow-emerald-500/30' : 'bg-slate-200 text-slate-700'}>
|
||||
{isPublished
|
||||
? translateCommon('events.status.published', 'Veröffentlicht')
|
||||
: translateCommon('events.status.draft', 'Entwurf')}
|
||||
</Badge>
|
||||
</div>
|
||||
<AppCard>
|
||||
<XStack justifyContent="space-between" alignItems="flex-start" space="$3">
|
||||
<YStack space="$1">
|
||||
<Text fontSize="$xs" letterSpacing={2.6} textTransform="uppercase" color="$color">
|
||||
{translate('events.list.item.label', 'Event')}
|
||||
</Text>
|
||||
<Text fontSize="$lg" fontWeight="700" color="$color">
|
||||
{renderName(event.name)}
|
||||
</Text>
|
||||
<MetaRow date={formatDate(event.event_date)} location={resolveLocation(event, translate)} status={statusLabel} />
|
||||
</YStack>
|
||||
<StatusPill tone={isPublished ? 'success' : 'warning'}>{statusLabel}</StatusPill>
|
||||
</XStack>
|
||||
|
||||
<div className="-mx-1 flex snap-x snap-mandatory gap-3 overflow-x-auto px-1">
|
||||
<XStack space="$2" flexWrap="wrap">
|
||||
{metaItems.map((item) => (
|
||||
<MetaChip key={item.key} icon={item.icon} label={item.label} value={item.value} />
|
||||
))}
|
||||
</div>
|
||||
</XStack>
|
||||
|
||||
{limitWarnings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{limitWarnings.length > 0 ? (
|
||||
<YStack space="$2">
|
||||
{limitWarnings.map((warning) => (
|
||||
<div
|
||||
<XStack
|
||||
key={warning.id}
|
||||
className={cn(
|
||||
'flex items-start gap-2 rounded-2xl border p-3 text-xs',
|
||||
warning.tone === 'danger'
|
||||
? 'border-rose-200/60 bg-rose-50 text-rose-900'
|
||||
: 'border-amber-200/60 bg-amber-50 text-amber-900',
|
||||
)}
|
||||
space="$2"
|
||||
alignItems="flex-start"
|
||||
borderWidth={1}
|
||||
borderRadius="$tile"
|
||||
padding="$3"
|
||||
backgroundColor={warning.tone === 'danger' ? '#fff1f2' : '#fffbeb'}
|
||||
borderColor={warning.tone === 'danger' ? '#fecdd3' : '#fef3c7'}
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span>{warning.message}</span>
|
||||
</div>
|
||||
<Text fontSize="$xs" color="$color">
|
||||
{warning.message}
|
||||
</Text>
|
||||
</XStack>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Button
|
||||
asChild
|
||||
className="rounded-full bg-brand-rose text-white shadow shadow-rose-400/40 hover:bg-brand-rose/90"
|
||||
<XStack space="$2">
|
||||
<Link
|
||||
to={ADMIN_EVENT_VIEW_PATH(slug)}
|
||||
className="flex-1 rounded-xl bg-[#007AFF] px-4 py-3 text-center text-sm font-semibold text-white shadow-sm transition hover:opacity-90"
|
||||
>
|
||||
<Link to={ADMIN_EVENT_VIEW_PATH(slug)}>
|
||||
{translateCommon('actions.open', 'Öffnen')} <ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="rounded-full border-rose-200 text-rose-600 hover:bg-rose-50">
|
||||
<Link to={ADMIN_EVENT_PHOTOS_PATH(slug)}>
|
||||
{translate('events.list.actions.photos', 'Fotos moderieren')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{translateCommon('actions.open', 'Öffnen')} <ArrowRight className="inline h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
to={ADMIN_EVENT_PHOTOS_PATH(slug)}
|
||||
className="flex-1 rounded-xl border border-slate-200 px-4 py-3 text-center text-sm font-semibold text-[#007AFF] transition hover:bg-slate-50"
|
||||
>
|
||||
{translate('events.list.actions.photos', 'Fotos moderieren')}
|
||||
</Link>
|
||||
</XStack>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<XStack flexWrap="wrap" space="$2">
|
||||
{secondaryLinks.map((action) => (
|
||||
<ActionChip key={action.key} to={action.to}>
|
||||
{action.label}
|
||||
</ActionChip>
|
||||
))}
|
||||
</div>
|
||||
</FrostedSurface>
|
||||
</XStack>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -264,22 +284,23 @@ function MetaChip({
|
||||
value: string | number;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-[55%] snap-center rounded-2xl border border-slate-200 bg-white p-3 text-left text-xs shadow-sm sm:min-w-0 dark:border-white/15 dark:bg-white/10 dark:text-white">
|
||||
<div className="flex items-center gap-2 text-slate-500 dark:text-slate-300">
|
||||
<YStack borderWidth={1} borderColor="$muted" borderRadius="$tile" padding="$3" minWidth="45%">
|
||||
<XStack alignItems="center" space="$2">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm font-semibold text-slate-900 dark:text-white">{value}</p>
|
||||
</div>
|
||||
<Text fontSize="$xs" color="$color">
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$md" fontWeight="700" color="$color" marginTop="$1">
|
||||
{value}
|
||||
</Text>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionChip({ to, children }: { to: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="inline-flex items-center rounded-full border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-rose-200 hover:bg-rose-50 hover:text-rose-700 dark:border-white/15 dark:text-slate-300 dark:hover:border-white/40 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
<Link to={to} className="rounded-full border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 hover:bg-slate-50">
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
@@ -287,14 +308,11 @@ function ActionChip({ to, children }: { to: string; children: React.ReactNode })
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<YStack space="$2">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<FrostedSurface
|
||||
key={index}
|
||||
className="h-24 animate-pulse rounded-3xl bg-gradient-to-r from-white/20 via-white/60 to-white/20"
|
||||
/>
|
||||
<AppCard key={index} height={96} opacity={0.6} />
|
||||
))}
|
||||
</div>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -308,21 +326,20 @@ function EmptyState({
|
||||
onCreate: () => void;
|
||||
}) {
|
||||
return (
|
||||
<FrostedSurface className="flex flex-col items-center justify-center gap-4 border-dashed border-pink-200/70 p-10 text-center">
|
||||
<div className="rounded-full bg-pink-100 p-3 text-pink-600 shadow-inner shadow-pink-200/80">
|
||||
<Plus className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-slate-900">{title}</h3>
|
||||
<p className="text-sm text-slate-600">{description}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onCreate}
|
||||
className="rounded-full bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 px-6 text-white shadow-lg shadow-pink-500/20"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" /> Event erstellen
|
||||
</Button>
|
||||
</FrostedSurface>
|
||||
<AppCard alignItems="center" justifyContent="center" space="$3" borderStyle="dashed" borderColor="$muted">
|
||||
<YStack bg="$muted" padding="$3" borderRadius="$pill">
|
||||
<Plus className="h-5 w-5 text-[#007AFF]" />
|
||||
</YStack>
|
||||
<YStack space="$1" alignItems="center">
|
||||
<Text fontSize="$lg" fontWeight="700" color="$color">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color="$color" textAlign="center">
|
||||
{description}
|
||||
</Text>
|
||||
</YStack>
|
||||
<PrimaryCTA label="Event erstellen" onPress={onCreate} />
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -346,3 +363,20 @@ function renderName(name: TenantEvent['name']): string {
|
||||
}
|
||||
return 'Unbenanntes Event';
|
||||
}
|
||||
|
||||
function resolveLocation(
|
||||
event: TenantEvent,
|
||||
translate: (key: string, fallback?: string, options?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
const settings = (event.settings ?? {}) as Record<string, unknown>;
|
||||
const candidate =
|
||||
(settings.location as string | undefined) ??
|
||||
(settings.address as string | undefined) ??
|
||||
(settings.city as string | undefined);
|
||||
|
||||
if (typeof candidate === 'string' && candidate.trim().length > 0) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return translate('events.list.meta.locationFallback', 'Ort folgt');
|
||||
}
|
||||
|
||||
@@ -23,6 +23,18 @@ const EventToolkitPage = React.lazy(() => import('./pages/EventToolkitPage'));
|
||||
const EventInvitesPage = React.lazy(() => import('./pages/EventInvitesPage'));
|
||||
const EventPhotoboothPage = React.lazy(() => import('./pages/EventPhotoboothPage'));
|
||||
const EventBrandingPage = React.lazy(() => import('./pages/EventBrandingPage'));
|
||||
const MobileEventsPage = React.lazy(() => import('./mobile/EventsPage'));
|
||||
const MobileEventDetailPage = React.lazy(() => import('./mobile/EventDetailPage'));
|
||||
const MobileBrandingPage = React.lazy(() => import('./mobile/BrandingPage'));
|
||||
const MobileEventFormPage = React.lazy(() => import('./mobile/EventFormPage'));
|
||||
const MobileQrPrintPage = React.lazy(() => import('./mobile/QrPrintPage'));
|
||||
const MobileEventPhotosPage = React.lazy(() => import('./mobile/EventPhotosPage'));
|
||||
const MobileEventMembersPage = React.lazy(() => import('./mobile/EventMembersPage'));
|
||||
const MobileEventTasksPage = React.lazy(() => import('./mobile/EventTasksPage'));
|
||||
const MobileAlertsPage = React.lazy(() => import('./mobile/AlertsPage'));
|
||||
const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage'));
|
||||
const MobileLoginPage = React.lazy(() => import('./mobile/LoginPage'));
|
||||
const MobileDashboardPage = React.lazy(() => import('./mobile/DashboardPage'));
|
||||
const EngagementPage = React.lazy(() => import('./pages/EngagementPage'));
|
||||
const BillingPage = React.lazy(() => import('./pages/BillingPage'));
|
||||
const TasksPage = React.lazy(() => import('./pages/TasksPage'));
|
||||
@@ -92,6 +104,7 @@ export const router = createBrowserRouter([
|
||||
children: [
|
||||
{ index: true, element: <LandingGate /> },
|
||||
{ path: 'login', element: <LoginPage /> },
|
||||
{ path: 'mobile/login', element: <MobileLoginPage /> },
|
||||
{ path: 'start', element: <LoginStartPage /> },
|
||||
{ path: 'logout', element: <LogoutPage /> },
|
||||
{ path: 'auth/callback', element: <AuthCallbackPage /> },
|
||||
@@ -112,6 +125,18 @@ export const router = createBrowserRouter([
|
||||
{ path: 'events/:slug/branding', element: <RequireAdminAccess><EventBrandingPage /></RequireAdminAccess> },
|
||||
{ path: 'events/:slug/photobooth', element: <RequireAdminAccess><EventPhotoboothPage /></RequireAdminAccess> },
|
||||
{ path: 'events/:slug/toolkit', element: <EventToolkitPage /> },
|
||||
{ path: 'mobile/events', element: <MobileEventsPage /> },
|
||||
{ path: 'mobile/events/:slug', element: <MobileEventDetailPage /> },
|
||||
{ path: 'mobile/events/:slug/branding', element: <RequireAdminAccess><MobileBrandingPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/new', element: <RequireAdminAccess><MobileEventFormPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/edit', element: <RequireAdminAccess><MobileEventFormPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/qr', element: <RequireAdminAccess><MobileQrPrintPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/photos', element: <MobileEventPhotosPage /> },
|
||||
{ path: 'mobile/events/:slug/members', element: <RequireAdminAccess><MobileEventMembersPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/events/:slug/tasks', element: <MobileEventTasksPage /> },
|
||||
{ path: 'mobile/alerts', element: <MobileAlertsPage /> },
|
||||
{ path: 'mobile/profile', element: <RequireAdminAccess><MobileProfilePage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/dashboard', element: <MobileDashboardPage /> },
|
||||
{ path: 'engagement', element: <EngagementPage /> },
|
||||
{ path: 'tasks', element: <TasksPage /> },
|
||||
{ path: 'task-collections', element: <TaskCollectionsPage /> },
|
||||
|
||||
159
resources/js/admin/tamagui/primitives.tsx
Normal file
159
resources/js/admin/tamagui/primitives.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React from 'react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Button } from '@tamagui/button';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { Home, BarChart2, Settings } from 'lucide-react';
|
||||
|
||||
export function AppCard({ children, padding = '$4', ...rest }: React.ComponentProps<typeof YStack> & { padding?: keyof typeof rest }) {
|
||||
return (
|
||||
<YStack
|
||||
bg="$surface"
|
||||
borderRadius="$card"
|
||||
borderWidth={1}
|
||||
borderColor="$muted"
|
||||
shadowColor="#0f172a"
|
||||
shadowOpacity={0.05}
|
||||
shadowRadius={12}
|
||||
shadowOffset={{ width: 0, height: 8 }}
|
||||
padding={padding as any}
|
||||
space="$3"
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusPill({ tone = 'muted', children }: { tone?: 'success' | 'warning' | 'muted'; children: React.ReactNode }) {
|
||||
const colors: Record<typeof tone, { bg: string; color: string; border: string }> = {
|
||||
success: { bg: '#ecfdf3', color: '#047857', border: '#bbf7d0' },
|
||||
warning: { bg: '#fffbeb', color: '#92400e', border: '#fef3c7' },
|
||||
muted: { bg: '#f3f4f6', color: '#374151', border: '#e5e7eb' },
|
||||
};
|
||||
const palette = colors[tone] ?? colors.muted;
|
||||
return (
|
||||
<XStack
|
||||
alignItems="center"
|
||||
paddingHorizontal="$2.5"
|
||||
paddingVertical="$1"
|
||||
borderRadius="$pill"
|
||||
borderWidth={1}
|
||||
backgroundColor={palette.bg}
|
||||
borderColor={palette.border}
|
||||
alignSelf="flex-start"
|
||||
>
|
||||
<Text fontSize={11} fontWeight="700" color={palette.color}>
|
||||
{children}
|
||||
</Text>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrimaryCTA({ label, onPress }: { label: string; onPress: () => void }) {
|
||||
return (
|
||||
<Button
|
||||
backgroundColor="$primary"
|
||||
color="white"
|
||||
height={56}
|
||||
borderRadius="$card"
|
||||
fontWeight="700"
|
||||
onPress={onPress}
|
||||
pressStyle={{ opacity: 0.9 }}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Segmented({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
options: Array<{ key: string; label: string }>;
|
||||
value: string;
|
||||
onChange: (key: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<XStack bg="$muted" borderRadius="$pill" borderWidth={1} borderColor="$muted" padding="$1" space="$1">
|
||||
{options.map((option) => {
|
||||
const active = option.key === value;
|
||||
return (
|
||||
<Pressable key={option.key} onPress={() => onChange(option.key)} style={{ flex: 1 }}>
|
||||
<YStack
|
||||
bg={active ? '$primary' : 'transparent'}
|
||||
borderRadius="$pill"
|
||||
paddingVertical="$2"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text color={active ? 'white' : '$color'} fontWeight="700" fontSize="$sm">
|
||||
{option.label}
|
||||
</Text>
|
||||
</YStack>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetaRow({ date, location, status }: { date: string; location: string; status: string }) {
|
||||
return (
|
||||
<YStack space="$1">
|
||||
<Text fontSize="$sm" color="$color">{date}</Text>
|
||||
<Text fontSize="$sm" color="$color">{location}</Text>
|
||||
<StatusPill tone="muted">{status}</StatusPill>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
export function BottomNav({
|
||||
active,
|
||||
onNavigate,
|
||||
}: {
|
||||
active: 'events' | 'analytics' | 'settings';
|
||||
onNavigate: (key: 'events' | 'analytics' | 'settings') => void;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const items = [
|
||||
{ key: 'events', icon: Home, label: 'Events' },
|
||||
{ key: 'analytics', icon: BarChart2, label: 'Analytics' },
|
||||
{ key: 'settings', icon: Settings, label: 'Settings' },
|
||||
];
|
||||
return (
|
||||
<XStack
|
||||
position="fixed"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bg="$background"
|
||||
borderTopWidth={1}
|
||||
borderColor="$muted"
|
||||
padding="$3"
|
||||
justifyContent="space-around"
|
||||
shadowColor="#0f172a"
|
||||
shadowOpacity={0.08}
|
||||
shadowRadius={12}
|
||||
shadowOffset={{ width: 0, height: -4 }}
|
||||
zIndex={50}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const activeState = item.key === active;
|
||||
const IconCmp = item.icon;
|
||||
return (
|
||||
<Pressable key={item.key} onPress={() => onNavigate(item.key as typeof active)}>
|
||||
<YStack alignItems="center" space="$1">
|
||||
<IconCmp size={20} color={activeState ? String(theme.primary?.val ?? '#007AFF') : '#9ca3af'} />
|
||||
<Text fontSize="$xs" color={activeState ? '$primary' : '$muted'}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</YStack>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
72
tamagui.config.ts
Normal file
72
tamagui.config.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { defaultConfig } from '@tamagui/config/v4'
|
||||
import { createTamagui } from '@tamagui/core';
|
||||
import { shorthands } from '@tamagui/shorthands';
|
||||
import { tokens as baseTokens, themes as baseThemes } from '@tamagui/themes';
|
||||
import { createAnimations } from '@tamagui/animations-css';
|
||||
|
||||
const tokens = {
|
||||
...baseTokens,
|
||||
color: {
|
||||
...baseTokens.color,
|
||||
primary: '#007AFF',
|
||||
accent: '#5AD2F4',
|
||||
success: '#2ECC71',
|
||||
warning: '#F5C542',
|
||||
danger: '#FF5F56',
|
||||
surface: '#ffffff',
|
||||
muted: '#f3f4f6',
|
||||
},
|
||||
radius: {
|
||||
...baseTokens.radius,
|
||||
card: 18,
|
||||
tile: 16,
|
||||
pill: 999,
|
||||
},
|
||||
size: {
|
||||
...baseTokens.size,
|
||||
card: 18,
|
||||
},
|
||||
};
|
||||
|
||||
const themes = {
|
||||
...baseThemes,
|
||||
light: {
|
||||
...baseThemes.light,
|
||||
primary: tokens.color.primary,
|
||||
accent: tokens.color.accent,
|
||||
background: '#f5f7fb',
|
||||
surface: tokens.color.surface,
|
||||
},
|
||||
dark: {
|
||||
...baseThemes.dark,
|
||||
primary: tokens.color.primary,
|
||||
accent: tokens.color.accent,
|
||||
background: '#0f172a',
|
||||
surface: '#111827',
|
||||
},
|
||||
};
|
||||
|
||||
const config = createTamagui({
|
||||
...defaultConfig,
|
||||
animations: createAnimations({
|
||||
fast: 'ease-in 150ms',
|
||||
medium: 'ease 250ms',
|
||||
slow: 'ease-out 400ms',
|
||||
}),
|
||||
tokens,
|
||||
themes,
|
||||
shorthands,
|
||||
media: {
|
||||
...defaultConfig.media,
|
||||
xs: { maxWidth: 660 },
|
||||
sm: { maxWidth: 840 },
|
||||
md: { maxWidth: 1024 },
|
||||
},
|
||||
});
|
||||
|
||||
export type AppConfig = typeof config;
|
||||
export default config;
|
||||
|
||||
declare module '@tamagui/core' {
|
||||
interface TamaguiCustomConfig extends AppConfig {}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import laravel from 'laravel-vite-plugin';
|
||||
import { defineConfig } from 'vite';
|
||||
import path from 'path';
|
||||
import { tamaguiPlugin } from '@tamagui/vite-plugin';
|
||||
|
||||
const devServerHost = process.env.VITE_DEV_SERVER_HOST ?? 'fotospiel-app.test';
|
||||
const devServerPort = Number.parseInt(process.env.VITE_DEV_SERVER_PORT ?? '5173', 10);
|
||||
@@ -78,6 +80,12 @@ export default defineConfig({
|
||||
wayfinder({
|
||||
formVariants: true,
|
||||
}),
|
||||
tamaguiPlugin({
|
||||
config: './tamagui.config.ts',
|
||||
components: ['@tamagui/core', '@tamagui/stacks', '@tamagui/text', '@tamagui/button'],
|
||||
optimize: false,
|
||||
disableExtraction: true,
|
||||
}),
|
||||
],
|
||||
esbuild: {
|
||||
jsx: 'automatic',
|
||||
@@ -88,6 +96,16 @@ export default defineConfig({
|
||||
exclude: [
|
||||
// füge notfalls große/selten genutzte Pakete hinzu
|
||||
],
|
||||
include: [
|
||||
'react-native-web',
|
||||
'@tamagui/core',
|
||||
'@tamagui/stacks',
|
||||
'@tamagui/text',
|
||||
'@tamagui/button',
|
||||
],
|
||||
},
|
||||
define: {
|
||||
'process.env.TAMAGUI_TARGET': JSON.stringify('web'),
|
||||
},
|
||||
|
||||
// Build-Optionen wirken vor allem bei `vite build`, schaden aber nicht:
|
||||
|
||||
Reference in New Issue
Block a user