mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:30:45 +00:00
Merge branch 'v30'
This commit is contained in:
+7
-5
@@ -208,11 +208,11 @@
|
||||
inline
|
||||
class="hidden w-full h-full page-content relative z-50"
|
||||
></join-lobby-modal>
|
||||
<territory-patterns-modal
|
||||
<store-modal
|
||||
id="page-item-store"
|
||||
inline
|
||||
class="hidden w-full h-full page-content relative z-50"
|
||||
></territory-patterns-modal>
|
||||
></store-modal>
|
||||
<user-setting
|
||||
id="page-settings"
|
||||
inline
|
||||
@@ -249,6 +249,11 @@
|
||||
inline
|
||||
class="hidden w-full h-full page-content relative z-50"
|
||||
></flag-input-modal>
|
||||
<territory-patterns-modal
|
||||
id="territory-patterns-modal"
|
||||
inline
|
||||
class="hidden w-full h-full page-content relative z-50"
|
||||
></territory-patterns-modal>
|
||||
<ranked-modal
|
||||
id="page-ranked"
|
||||
inline
|
||||
@@ -263,9 +268,6 @@
|
||||
</div>
|
||||
|
||||
<!-- Global Modals -->
|
||||
<territory-patterns-modal
|
||||
id="territory-patterns-modal"
|
||||
></territory-patterns-modal>
|
||||
</div>
|
||||
|
||||
<!-- Game components -->
|
||||
|
||||
@@ -1,531 +0,0 @@
|
||||
{
|
||||
"role_groups": {
|
||||
"donor": ["1359441841371480176", "1330243292306341969"],
|
||||
"creator": ["1286745100411473930"]
|
||||
},
|
||||
"patterns": {
|
||||
"ABMIVVU": {
|
||||
"name": "stripes_v"
|
||||
},
|
||||
"ABMIDw8": {
|
||||
"name": "stripes_h"
|
||||
},
|
||||
"AAEYAwA": {
|
||||
"name": "horizontal_stripes",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"AAoACQ": {
|
||||
"name": "vertical_bars",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"ABMIpaU": {
|
||||
"name": "checkerboard"
|
||||
},
|
||||
"AFIoAAABOEAHgkAc-AN_4AMcgAAA": {
|
||||
"name": "choco"
|
||||
},
|
||||
"AHE4AQACAAQACAAQACAAQACAAAABAAIABAAIABAAIABAAIA": {
|
||||
"name": "diagonal",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"AHE4AYACQAQgCBAQCCAEQAKAAYABQAIgBBAICBAEIAJAAYA": {
|
||||
"name": "cross",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"AHEYA8AMMDAMwAPAAzAMDDADwA": {
|
||||
"name": "mini_cross",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"AHI4AOAAkACIAEQAIgARjAhUBCgCKAHQACgB1AILAwUABwA": {
|
||||
"name": "sword",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"AHE4AQEAAAAAAAAAAAAAAAAAAAEBAAAAAAAAAAAAAAAAAAA": {
|
||||
"name": "sparse_dots",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"ALIUAAAAnsRIgiRZjuRpAiNJHiNJAAAA": {
|
||||
"name": "evan",
|
||||
"role_group": "creator"
|
||||
},
|
||||
"AHEYAYACQAQgCBAQCCAEQAKAAQ": {
|
||||
"name": "diagonal_stripe",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"AHEYAAAYGDw8fn7__35-PDwYGA": {
|
||||
"name": "mountain_ridge",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"AHEYAAACIAAAAAAAAAAACBAAAA": {
|
||||
"name": "scattered_dots",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"AHEYw8PDwwwMDAwwDDAMw8PDww": {
|
||||
"name": "circuit_board",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"AHEgzGfznzu43XPoL2fMn_O4O3fdL-g": {
|
||||
"name": "shells",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"AHEYAAAAAAAAAkCCQUQiLnQWaA": {
|
||||
"name": "-w-",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"AHE4AAAAAKAAUAFQAVABCC4EUCQgJCAEIDgm0BBwDwAAAAA": {
|
||||
"name": "white_rabbit",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"AKFwAAAADMAABSiAAgVAoQBQKAA0CwB6AfDhAwIAQQKQkAAkMgCTSkhlGpYBAQJAjAAgEAAQAgB6AUCAABAgAAoUgAIFoFIBqFcAKhWASgXA8wAAAAA": {
|
||||
"name": "goat",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"ALF1AAAAAAAAABABAAAAAACwAQAAAAAA8AEAAAAAIFABIoABABDwATbAA4gQ9AU-wBPZIPgDKoSx-UD4Az4C9Kmf8AEcUlj5v_ABPvb9-X_wAX7k__A_8AN-_H_gH_AFfuJ_wBiwJf_BP0AIEBkAAAAAAAAAAAAAAAAAAAAAAAAAIgAAAAAAADYAiAAA-AA-ANgAELEBKgD4ALDhI_4HqgDwsSf-H_oIUPlH_C9xBP_BT_gn-YT_wD_8J_rEf-Af_hP8yD_gHwAM-NAf4B8AAvzhP8APAAAAAAAAAA": {
|
||||
"name": "cats",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"AJhYAAAAGACABACQAAASAEACAMgBAMkBIMkAJCngJAkSIEECIEgABAKAQAAQEAACAiCAAAQQgAAECIAAAfA_AAAA": {
|
||||
"name": "hand",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"AMFYAAAAAAAAAAzAADgAB_ABPuAH-IE_8Af_4T_8h__wz_zDv_cPAB4AADAAAAAAAIAHAAAeAAD8AADwAwDgHwCAfwAA_wMA8AMAAAAAAAAA": {
|
||||
"name": "radiation",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"AJhMAAAABACAAQBwAAAeAMAHAPgBAH8A4B8A_AeA_wHwfwD-H8D_A_gHAO8B4DwADA-A4AEAGAAAAAA": {
|
||||
"name": "cursor",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"AMpkAAAA8DfCnyAQgnTl0KVrvS5d73UJkinIX1V_ABQD8BQ8l5WRRViBLOAEHw7_CtbhfP-ItdU0AmI8wrTwH4DbqPxpVCdIMhJdOL_our9PV681gg6s8BeFHwAAAAA": {
|
||||
"name": "openfront_qr",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"AAIiAAAAAAAAAAAAAAAAAAAAAIDD8YnweTiiD5FIYEIgEpkIRCKBCoFIpCIQeTwyPB6RjEAkEIgQKEQiApFAIEIgEYkIOAKfCIGIIyIAAAAAAAAAAAA": {
|
||||
"name": "openfront",
|
||||
"role_group": "creator"
|
||||
},
|
||||
"AJlMAAAAAP8A8D8A9gfA_wD4HwAfAOAfAn5A4A8Y_wf3v8D_B_j_AP4PgP8B4B0AGAEAIQBgDAAAAAA": {
|
||||
"name": "t_rex",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"AAqFAAACAAAAOAAAAOADAAAAHwAAAPgAAEDABwQAwZxBAAzuDgYw8H9ggAH-AAMO8Ac4OAAfgMMB-AAcDsAH4HgAPgDPB_ABfD6AD-DzAXwAnx_gA_z8gT_w5x_-wz______-f___4____8__P___8H___8H_v__P-D___8A____B_D__x8A__9_APD__wEA_v8DAMD_BwAA-A8AAA": {
|
||||
"name": "embelem",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"AMo0AAAAAAAAAAAAAIAAJAACEAIIQCAgAAGCAAQgCBCAgEAAAggBCIAEIAAAAAAAAAAAAAAA": {
|
||||
"name": "contributor"
|
||||
},
|
||||
"AMlNAAAAAAAAAAAAAPAfAACAHwDwgAcAAMQf4ADgAAAgwM8BAAwACEPABwDA__8xEACIAAAACAMOgCQAAGEwwAAoAPCAAQGMCCBhAAYIQPwAnwEYgADSB_QEQAAIkD_wJwABgAH8gx8ABAAYwE99ABgAAAcAACBwAADgBDCA-AAAADwQoH8AAAAA_v8DAAAAAAAMAAAAAAAAAAAAAAA": {
|
||||
"name": "grogu_head",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"AMl9AAAAAAAAAAAAAPAfAACAHwDwgAcAAMQf4ADgAAAgwM8BAAwACEPABwDA__8xEACIAAAACAMOgCQAAGEwwAAoAPCAAQGMCCBhAAYIQPwAnwEYgADSB_QEQAAIkD_wJwABgAH8gx8ABAAYwE99ABgAAAcAACBwAADgBDCA-AAAADwQoH8AAABA_v8DBwAAAAIMACwAAAAwAAAwAQAAAAMAoBgAAAA4COCFAAAAYEbAIAcAAICjgzEbAAAALPwHSQAAAGAjAAoDAAAAJknIDAAAAFBEIj8AAACAOpA4AAAAAHyAwAEAAAAAQQQIAAAAAAAAAAAAAA": {
|
||||
"name": "grogu",
|
||||
"role_group": "donor"
|
||||
}
|
||||
},
|
||||
"flag": {
|
||||
"layers": {
|
||||
"a": {
|
||||
"name": "center_circle",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"b": {
|
||||
"name": "center_hline",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"c": {
|
||||
"name": "center_vline",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"d": {
|
||||
"name": "center_star",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"e": {
|
||||
"name": "center_flower",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"f": {
|
||||
"name": "flower_tl",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"g": {
|
||||
"name": "flower_tc",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"h": {
|
||||
"name": "flower_tr",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"i": {
|
||||
"name": "diag_br",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"j": {
|
||||
"name": "diag_bl",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"k": {
|
||||
"name": "frame"
|
||||
},
|
||||
"l": {
|
||||
"name": "full"
|
||||
},
|
||||
"m": {
|
||||
"name": "triangle_tl",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"n": {
|
||||
"name": "triangle_bl",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"o": {
|
||||
"name": "triangle_tr",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"p": {
|
||||
"name": "triangle_br",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"q": {
|
||||
"name": "half_l",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"r": {
|
||||
"name": "half_r",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"s": {
|
||||
"name": "half_t",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"t": {
|
||||
"name": "half_b",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"u": {
|
||||
"name": "mini_tr_bl",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"v": {
|
||||
"name": "mini_tr_br",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"w": {
|
||||
"name": "mini_tr_tl",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"x": {
|
||||
"name": "mini_tr_tr",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"y": {
|
||||
"name": "triangle_t",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"z": {
|
||||
"name": "triangle_l",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"aa": {
|
||||
"name": "triangle_b",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"ab": {
|
||||
"name": "triangle_r",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"ac": {
|
||||
"name": "tricolor_l",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"ad": {
|
||||
"name": "tricolor_c",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"ae": {
|
||||
"name": "tricolor_r",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"af": {
|
||||
"name": "tricolor_t",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"ag": {
|
||||
"name": "tricolor_m",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"ah": {
|
||||
"name": "tricolor_b",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"ai": {
|
||||
"name": "nato_emblem",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"aj": {
|
||||
"name": "eu_star",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"ak": {
|
||||
"name": "laurel_wreath",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"al": {
|
||||
"name": "ofm_2025",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"am": {
|
||||
"name": "octagram",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"an": {
|
||||
"name": "octagram_2",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"ao": {
|
||||
"name": "og",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"ap": {
|
||||
"name": "og_plus",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"aq": {
|
||||
"name": "beta_tester",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"ar": {
|
||||
"name": "beta_tester_circle",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"as": {
|
||||
"name": "rocket",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"at": {
|
||||
"name": "rocket_mini",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"au": {
|
||||
"name": "translator",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"av": {
|
||||
"name": "admin_shield",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"aw": {
|
||||
"name": "admin_shield_r",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"ax": {
|
||||
"name": "admin_evan",
|
||||
"role_group": "donor"
|
||||
}
|
||||
},
|
||||
"color": {
|
||||
"a": {
|
||||
"color": "#ff0000",
|
||||
"name": "red",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"b": {
|
||||
"color": "#ffa500",
|
||||
"name": "orange",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"c": {
|
||||
"color": "#ffff00",
|
||||
"name": "yellow",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"d": {
|
||||
"color": "#008000",
|
||||
"name": "green",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"e": {
|
||||
"color": "#00ffff",
|
||||
"name": "cyan",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"f": {
|
||||
"color": "#0000ff",
|
||||
"name": "blue",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"g": {
|
||||
"color": "#000000",
|
||||
"name": "black",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"h": {
|
||||
"color": "#ffffff",
|
||||
"name": "white",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"i": {
|
||||
"color": "#800080",
|
||||
"name": "purple",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"j": {
|
||||
"color": "#ff69b4",
|
||||
"name": "hotpink",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"k": {
|
||||
"color": "#a52a2a",
|
||||
"name": "brown",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"l": {
|
||||
"color": "#808080",
|
||||
"name": "gray",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"m": {
|
||||
"color": "#20b2aa",
|
||||
"name": "teal",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"n": {
|
||||
"color": "#ff6347",
|
||||
"name": "tomato",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"o": {
|
||||
"color": "#4682b4",
|
||||
"name": "steelblue",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"p": {
|
||||
"color": "#90ee90",
|
||||
"name": "lightgreen",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"q": {
|
||||
"color": "#8b0000",
|
||||
"name": "darkred",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"r": {
|
||||
"color": "#191970",
|
||||
"name": "navy",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"s": {
|
||||
"color": "#ffd700",
|
||||
"name": "gold",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"t": {
|
||||
"color": "#add8e6",
|
||||
"name": "lightblue",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"u": {
|
||||
"color": "#f5f5dc",
|
||||
"name": "beige",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"v": {
|
||||
"color": "#ffb6c1",
|
||||
"name": "lightpink",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"w": {
|
||||
"color": "#708090",
|
||||
"name": "slategray",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"x": {
|
||||
"color": "#00ff7f",
|
||||
"name": "springgreen",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"y": {
|
||||
"color": "#dc143c",
|
||||
"name": "crimson",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"z": {
|
||||
"color": "#ffbf00",
|
||||
"name": "amber",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"0": {
|
||||
"color": "#3d9970",
|
||||
"name": "olive_green",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"1": {
|
||||
"color": "#87ceeb",
|
||||
"name": "sky_blue",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"2": {
|
||||
"color": "#6a5acd",
|
||||
"name": "slate_blue",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"3": {
|
||||
"color": "#ff66cc",
|
||||
"name": "rose_pink",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"4": {
|
||||
"color": "#36454f",
|
||||
"name": "charcoal",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"5": {
|
||||
"color": "#fffff0",
|
||||
"name": "ivory",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"A": {
|
||||
"color": "rainbow",
|
||||
"name": "rainbow",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"B": {
|
||||
"color": "bright-rainbow",
|
||||
"name": "bright_rainbow",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"C": {
|
||||
"color": "gold-glow",
|
||||
"name": "gold_glow",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"D": {
|
||||
"color": "silver-glow",
|
||||
"name": "silver_glow",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"E": {
|
||||
"color": "copper-glow",
|
||||
"name": "copper_glow",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"F": {
|
||||
"color": "neon",
|
||||
"name": "neon",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"G": {
|
||||
"color": "lava",
|
||||
"name": "lava",
|
||||
"role_group": "donor"
|
||||
},
|
||||
"H": {
|
||||
"color": "water",
|
||||
"name": "water",
|
||||
"role_group": "donor"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-3
@@ -928,12 +928,16 @@
|
||||
"pvp_immunity_active": "PVP immunity active for {seconds}s",
|
||||
"catching_up": "Catching up..."
|
||||
},
|
||||
"store": {
|
||||
"title": "Store",
|
||||
"patterns": "Skins",
|
||||
"flags": "Flags",
|
||||
"no_flags": "No flags available. Check back later for new items.",
|
||||
"no_skins": "No skins available. Check back later for new items."
|
||||
},
|
||||
"territory_patterns": {
|
||||
"title": "Skins",
|
||||
"colors": "Colors",
|
||||
"purchase": "Purchase",
|
||||
"show_only_owned": "My Skins",
|
||||
"all_owned": "All skins owned! Check back later for new items.",
|
||||
"not_logged_in": "Not logged in",
|
||||
"pattern": {
|
||||
"default": "Default"
|
||||
@@ -941,6 +945,9 @@
|
||||
"select_skin": "Select Skin",
|
||||
"selected": "selected"
|
||||
},
|
||||
"cosmetics": {
|
||||
"artist_label": "Artist:"
|
||||
},
|
||||
"flag_input": {
|
||||
"title": "Select Flag",
|
||||
"button_title": "Pick a flag!",
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ export async function getUserMe(): Promise<UserMeResponse | false> {
|
||||
|
||||
export async function createCheckoutSession(
|
||||
priceId: string,
|
||||
colorPaletteName: string | null,
|
||||
colorPaletteName?: string,
|
||||
): Promise<string | false> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { decodeJwt } from "jose";
|
||||
import { UserSettings } from "src/core/game/UserSettings";
|
||||
import { z } from "zod";
|
||||
import { TokenPayload, TokenPayloadSchema } from "../core/ApiSchemas";
|
||||
import { base64urlToUuid } from "../core/Base64";
|
||||
@@ -63,6 +64,8 @@ export async function logOut(allSessions: boolean = false): Promise<boolean> {
|
||||
} finally {
|
||||
__jwt = null;
|
||||
localStorage.removeItem(PERSISTENT_ID_KEY);
|
||||
new UserSettings().clearFlag();
|
||||
new UserSettings().setSelectedPatternName(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+141
-45
@@ -1,9 +1,11 @@
|
||||
import { assetUrl } from "src/core/AssetUrls";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import {
|
||||
ColorPalette,
|
||||
Cosmetics,
|
||||
CosmeticsSchema,
|
||||
Flag,
|
||||
Pattern,
|
||||
Product,
|
||||
} from "../core/CosmeticSchemas";
|
||||
import {
|
||||
PlayerCosmeticRefs,
|
||||
@@ -12,34 +14,26 @@ import {
|
||||
} from "../core/Schemas";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import { createCheckoutSession, getApiBase, getUserMe } from "./Api";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
export const TEMP_FLARE_OFFSET = 1 * 60 * 1000; // 1 minute
|
||||
|
||||
export async function handlePurchase(
|
||||
pattern: Pattern,
|
||||
colorPalette: ColorPalette | null,
|
||||
) {
|
||||
if (pattern.product === null) {
|
||||
alert("This pattern is not available for purchase.");
|
||||
return;
|
||||
}
|
||||
let __cosmetics: Promise<Cosmetics | null> | null = null;
|
||||
let __cosmeticsHash: string | null = null;
|
||||
|
||||
const url = await createCheckoutSession(
|
||||
pattern.product.priceId,
|
||||
colorPalette?.name ?? null,
|
||||
);
|
||||
export async function handlePurchase(
|
||||
product: Product,
|
||||
colorPaletteName?: string,
|
||||
) {
|
||||
const url = await createCheckoutSession(product.priceId, colorPaletteName);
|
||||
if (url === false) {
|
||||
alert("Failed to create checkout session.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to Stripe checkout
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
let __cosmetics: Promise<Cosmetics | null> | null = null;
|
||||
let __cosmeticsHash: string | null = null;
|
||||
|
||||
function simpleHash(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
@@ -80,54 +74,118 @@ export async function fetchCosmetics(): Promise<Cosmetics | null> {
|
||||
return __cosmetics;
|
||||
}
|
||||
|
||||
export async function resolveFlagUrl(
|
||||
flagRef: string,
|
||||
): Promise<string | undefined> {
|
||||
if (flagRef.startsWith("flag:")) {
|
||||
const key = flagRef.slice("flag:".length);
|
||||
const cosmetics = await fetchCosmetics();
|
||||
const flagData = cosmetics?.flags?.[key];
|
||||
return flagData?.url;
|
||||
}
|
||||
if (flagRef.startsWith("country:")) {
|
||||
const code = flagRef.slice("country:".length);
|
||||
return assetUrl(`flags/${code}.svg`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function getCosmeticsHash(): Promise<string | null> {
|
||||
await fetchCosmetics();
|
||||
return __cosmeticsHash;
|
||||
}
|
||||
|
||||
export function cosmeticRelationship(
|
||||
opts: {
|
||||
wildcardFlare: string;
|
||||
requiredFlare: string;
|
||||
product: Product | null;
|
||||
affiliateCode: string | null;
|
||||
itemAffiliateCode: string | null;
|
||||
},
|
||||
userMeResponse: UserMeResponse | false,
|
||||
): "owned" | "purchasable" | "blocked" {
|
||||
const flares =
|
||||
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
|
||||
|
||||
if (flares.includes(opts.wildcardFlare)) {
|
||||
return "owned";
|
||||
}
|
||||
|
||||
if (flares.includes(opts.requiredFlare)) {
|
||||
return "owned";
|
||||
}
|
||||
|
||||
if (opts.product === null) {
|
||||
return "blocked";
|
||||
}
|
||||
|
||||
if (opts.affiliateCode !== opts.itemAffiliateCode) {
|
||||
return "blocked";
|
||||
}
|
||||
|
||||
return "purchasable";
|
||||
}
|
||||
|
||||
export function patternRelationship(
|
||||
pattern: Pattern,
|
||||
colorPalette: { name: string; isArchived?: boolean } | null,
|
||||
userMeResponse: UserMeResponse | false,
|
||||
affiliateCode: string | null,
|
||||
): "owned" | "purchasable" | "blocked" {
|
||||
const flares =
|
||||
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
|
||||
if (flares.includes("pattern:*")) {
|
||||
return "owned";
|
||||
}
|
||||
|
||||
if (colorPalette === null) {
|
||||
// For backwards compatibility only show non-colored patterns if they are owned.
|
||||
if (flares.includes(`pattern:${pattern.name}`)) {
|
||||
const flares =
|
||||
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
|
||||
if (
|
||||
flares.includes("pattern:*") ||
|
||||
flares.includes(`pattern:${pattern.name}`)
|
||||
) {
|
||||
return "owned";
|
||||
}
|
||||
return "blocked";
|
||||
}
|
||||
|
||||
const requiredFlare = `pattern:${pattern.name}:${colorPalette.name}`;
|
||||
|
||||
if (flares.includes(requiredFlare)) {
|
||||
return "owned";
|
||||
}
|
||||
|
||||
if (pattern.product === null) {
|
||||
// We don't own it and it's not for sale, so don't show it.
|
||||
if (colorPalette.isArchived) {
|
||||
// Check ownership first — if owned, show it even if archived.
|
||||
const flares =
|
||||
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
|
||||
if (
|
||||
flares.includes("pattern:*") ||
|
||||
flares.includes(`pattern:${pattern.name}:${colorPalette.name}`)
|
||||
) {
|
||||
return "owned";
|
||||
}
|
||||
return "blocked";
|
||||
}
|
||||
|
||||
if (colorPalette?.isArchived) {
|
||||
// We don't own the color palette, and it's archived, so don't show it.
|
||||
return "blocked";
|
||||
}
|
||||
return cosmeticRelationship(
|
||||
{
|
||||
wildcardFlare: "pattern:*",
|
||||
requiredFlare: `pattern:${pattern.name}:${colorPalette.name}`,
|
||||
product: pattern.product,
|
||||
affiliateCode,
|
||||
itemAffiliateCode: pattern.affiliateCode,
|
||||
},
|
||||
userMeResponse,
|
||||
);
|
||||
}
|
||||
|
||||
if (affiliateCode !== pattern.affiliateCode) {
|
||||
// Pattern is for sale, but it's not the right store to show it on.
|
||||
return "blocked";
|
||||
}
|
||||
|
||||
// Patterns is for sale, and it's the right store to show it on.
|
||||
return "purchasable";
|
||||
export function flagRelationship(
|
||||
flag: Flag,
|
||||
userMeResponse: UserMeResponse | false,
|
||||
affiliateCode: string | null,
|
||||
): "owned" | "purchasable" | "blocked" {
|
||||
return cosmeticRelationship(
|
||||
{
|
||||
wildcardFlare: "flag:*",
|
||||
requiredFlare: `flag:${flag.name}`,
|
||||
product: flag.product,
|
||||
affiliateCode,
|
||||
itemAffiliateCode: flag.affiliateCode,
|
||||
},
|
||||
userMeResponse,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
|
||||
@@ -154,8 +212,34 @@ export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
|
||||
}
|
||||
}
|
||||
|
||||
let flag = userSettings.getFlag();
|
||||
if (flag?.startsWith("flag:")) {
|
||||
const key = flag.slice("flag:".length);
|
||||
const flagData = cosmetics?.flags?.[key];
|
||||
if (!flagData) {
|
||||
// Only clear if cosmetics loaded successfully but the key is missing
|
||||
if (cosmetics) {
|
||||
flag = null;
|
||||
}
|
||||
} else {
|
||||
const userMe = await getUserMe();
|
||||
if (!userMe) {
|
||||
flag = null;
|
||||
} else {
|
||||
const flares = userMe.player.flares ?? [];
|
||||
const hasWildcard = flares.includes("flag:*");
|
||||
if (!hasWildcard && !flares.includes(`flag:${flagData.name}`)) {
|
||||
flag = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (flag === null) {
|
||||
userSettings.clearFlag();
|
||||
}
|
||||
|
||||
return {
|
||||
flag: userSettings.getFlag(),
|
||||
flag: flag ?? undefined,
|
||||
color: userSettings.getSelectedColor() ?? undefined,
|
||||
patternName: pattern?.name ?? undefined,
|
||||
patternColorPaletteName: pattern?.colorPalette?.name ?? undefined,
|
||||
@@ -169,7 +253,7 @@ export async function getPlayerCosmetics(): Promise<PlayerCosmetics> {
|
||||
const result: PlayerCosmetics = {};
|
||||
|
||||
if (refs.flag) {
|
||||
result.flag = refs.flag;
|
||||
result.flag = await resolveFlagUrl(refs.flag);
|
||||
}
|
||||
|
||||
if (refs.color) {
|
||||
@@ -202,3 +286,15 @@ export async function getPlayerCosmetics(): Promise<PlayerCosmetics> {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function translateCosmetic(prefix: string, name: string): string {
|
||||
const translation = translateText(`${prefix}.${name}`);
|
||||
if (translation.startsWith(prefix)) {
|
||||
return name
|
||||
.split("_")
|
||||
.filter((word) => word.length > 0)
|
||||
.map((word) => word[0].toUpperCase() + word.substring(1))
|
||||
.join(" ");
|
||||
}
|
||||
return translation;
|
||||
}
|
||||
|
||||
+30
-56
@@ -1,12 +1,10 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { assetUrl } from "../core/AssetUrls";
|
||||
import { renderPlayerFlag } from "../core/CustomFlag";
|
||||
import { FlagSchema } from "../core/Schemas";
|
||||
import { FlagName } from "../core/Schemas";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import { resolveFlagUrl } from "./Cosmetics";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
const flagKey: string = "flag";
|
||||
|
||||
@customElement("flag-input")
|
||||
export class FlagInput extends LitElement {
|
||||
@state() public flag: string = "";
|
||||
@@ -15,36 +13,16 @@ export class FlagInput extends LitElement {
|
||||
public showSelectLabel: boolean = false;
|
||||
|
||||
private isDefaultFlagValue(flag: string): boolean {
|
||||
return !flag || flag === "xx";
|
||||
return !flag || flag === "xx" || flag === "country:xx";
|
||||
}
|
||||
|
||||
public getCurrentFlag(): string {
|
||||
return this.flag;
|
||||
}
|
||||
|
||||
private getStoredFlag(): string {
|
||||
const storedFlag = localStorage.getItem(flagKey);
|
||||
if (storedFlag) {
|
||||
return storedFlag;
|
||||
private updateFlag = (e: CustomEvent) => {
|
||||
const parsed = FlagName.safeParse(e.detail);
|
||||
if (!parsed.success) {
|
||||
console.warn(`error parsing flag ${e.detail.value}, ${parsed.error}`);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private dispatchFlagEvent() {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("flag-change", {
|
||||
detail: { flag: this.flag },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private updateFlag = (ev: Event) => {
|
||||
const e = ev as CustomEvent<{ flag: string }>;
|
||||
if (!FlagSchema.safeParse(e.detail.flag).success) return;
|
||||
if (this.flag !== e.detail.flag) {
|
||||
this.flag = e.detail.flag;
|
||||
if (this.flag !== e.detail) {
|
||||
this.flag = e.detail;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -61,14 +39,19 @@ export class FlagInput extends LitElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.flag = this.getStoredFlag();
|
||||
this.dispatchFlagEvent();
|
||||
window.addEventListener("flag-change", this.updateFlag as EventListener);
|
||||
this.flag = new UserSettings().getFlag() ?? "";
|
||||
window.addEventListener(
|
||||
"event:user-settings-changed:flag",
|
||||
this.updateFlag as EventListener,
|
||||
);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("flag-change", this.updateFlag as EventListener);
|
||||
window.removeEventListener(
|
||||
"event:user-settings-changed:flag",
|
||||
this.updateFlag as EventListener,
|
||||
);
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
@@ -95,7 +78,7 @@ export class FlagInput extends LitElement {
|
||||
></span>
|
||||
${showSelect
|
||||
? html`<span
|
||||
class="text-[10px] font-medium tracking-wider text-white uppercase leading-none break-words w-full text-center px-1"
|
||||
class="text-[7px] lg:text-[10px] font-black tracking-wider text-white uppercase leading-tight lg:leading-none w-full text-center px-0.5 lg:px-1"
|
||||
>
|
||||
${translateText("flag_input.title")}
|
||||
</span>`
|
||||
@@ -104,35 +87,26 @@ export class FlagInput extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
updated() {
|
||||
async updated() {
|
||||
const preview = this.renderRoot.querySelector(
|
||||
"#flag-preview",
|
||||
) as HTMLElement;
|
||||
if (!preview) return;
|
||||
|
||||
if (this.showSelectLabel && this.isDefaultFlagValue(this.flag)) {
|
||||
if (this.isDefaultFlagValue(this.flag)) {
|
||||
preview.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
preview.innerHTML = "";
|
||||
|
||||
if (this.flag?.startsWith("!")) {
|
||||
renderPlayerFlag(this.flag, preview);
|
||||
} else {
|
||||
const img = document.createElement("img");
|
||||
const fallbackFlagUrl = assetUrl("flags/xx.svg");
|
||||
img.src = this.flag
|
||||
? assetUrl(`flags/${this.flag}.svg`)
|
||||
: fallbackFlagUrl;
|
||||
img.className = "w-full h-full object-cover pointer-events-none";
|
||||
img.draggable = false;
|
||||
img.onerror = () => {
|
||||
if (!img.src.endsWith(fallbackFlagUrl)) {
|
||||
img.src = fallbackFlagUrl;
|
||||
}
|
||||
};
|
||||
preview.appendChild(img);
|
||||
}
|
||||
const url = await resolveFlagUrl(this.flag);
|
||||
if (!url) return;
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.src = url;
|
||||
img.className = "w-full h-full object-cover pointer-events-none";
|
||||
img.draggable = false;
|
||||
preview.appendChild(img);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,98 @@
|
||||
import { html } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import Countries from "resources/countries.json" with { type: "json" };
|
||||
import { assetUrl } from "../core/AssetUrls";
|
||||
import { UserMeResponse } from "src/core/ApiSchemas";
|
||||
import { Cosmetics } from "src/core/CosmeticSchemas";
|
||||
import { UserSettings } from "src/core/game/UserSettings";
|
||||
import { getUserMe } from "./Api";
|
||||
import { fetchCosmetics, flagRelationship } from "./Cosmetics";
|
||||
import { translateText } from "./Utils";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/FlagButton";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
|
||||
@customElement("flag-input-modal")
|
||||
export class FlagInputModal extends BaseModal {
|
||||
@query("#flag-input-modal") private modalRef!: HTMLElement;
|
||||
|
||||
@state() private search = "";
|
||||
@state() private cosmetics: Cosmetics | null = null;
|
||||
@state() private userMe: UserMeResponse | false = false;
|
||||
public returnTo = "";
|
||||
|
||||
updated(changedProperties: Map<string | number | symbol, unknown>) {
|
||||
super.updated(changedProperties);
|
||||
}
|
||||
|
||||
private renderFlags() {
|
||||
const userSettings = new UserSettings();
|
||||
const selectedFlag = userSettings.getFlag() ?? "";
|
||||
const onSelect = (flagKey: string) => {
|
||||
this.setFlag(flagKey);
|
||||
this.close();
|
||||
};
|
||||
|
||||
const cosmeticFlags = Object.entries(this.cosmetics?.flags ?? {})
|
||||
.filter(([, flag]) => {
|
||||
if (!this.includedInSearch({ name: flag.name, code: flag.name }))
|
||||
return false;
|
||||
return flagRelationship(flag, this.userMe, null) === "owned";
|
||||
})
|
||||
.map(
|
||||
([key, flag]) => html`
|
||||
<flag-button
|
||||
.flag=${{
|
||||
key: `flag:${key}`,
|
||||
name: flag.name,
|
||||
url: flag.url,
|
||||
artist: flag.artist,
|
||||
}}
|
||||
.selected=${selectedFlag === `flag:${key}`}
|
||||
.onSelect=${onSelect}
|
||||
></flag-button>
|
||||
`,
|
||||
);
|
||||
|
||||
const noFlag = this.search
|
||||
? null
|
||||
: html`
|
||||
<flag-button
|
||||
.flag=${{
|
||||
key: "country:xx",
|
||||
name: "None",
|
||||
url: "/flags/xx.svg",
|
||||
}}
|
||||
.selected=${selectedFlag === "" || selectedFlag === "country:xx"}
|
||||
.onSelect=${onSelect}
|
||||
></flag-button>
|
||||
`;
|
||||
|
||||
const countryFlags = Countries.filter(
|
||||
(country) =>
|
||||
country.code !== "xx" &&
|
||||
!country.restricted &&
|
||||
this.includedInSearch(country),
|
||||
).map(
|
||||
(country) => html`
|
||||
<flag-button
|
||||
.flag=${{
|
||||
key: `country:${country.code}`,
|
||||
name: country.name,
|
||||
url: `/flags/${country.code}.svg`,
|
||||
}}
|
||||
.selected=${selectedFlag === `country:${country.code}`}
|
||||
.onSelect=${onSelect}
|
||||
></flag-button>
|
||||
`,
|
||||
);
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="pt-1 flex flex-wrap gap-1.5 justify-center items-stretch content-start"
|
||||
>
|
||||
${noFlag} ${cosmeticFlags} ${countryFlags}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
const content = html`
|
||||
<div class="${this.modalContainerClass}">
|
||||
@@ -36,6 +112,7 @@ export class FlagInputModal extends BaseModal {
|
||||
focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 text-white placeholder-white/30 transition-all"
|
||||
type="text"
|
||||
placeholder=${translateText("flag_input.search_flag")}
|
||||
.value=${this.search}
|
||||
@change=${this.handleSearch}
|
||||
@keyup=${this.handleSearch}
|
||||
/>
|
||||
@@ -43,43 +120,9 @@ export class FlagInputModal extends BaseModal {
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1 overflow-y-auto px-6 pb-6 scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent mr-1"
|
||||
class="flex-1 overflow-y-auto px-3 pb-3 scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent mr-1"
|
||||
>
|
||||
<div class="pt-2 flex flex-wrap justify-center gap-4 min-h-min">
|
||||
${Countries.filter(
|
||||
(country) =>
|
||||
!country.restricted && this.includedInSearch(country),
|
||||
).map(
|
||||
(country) => html`
|
||||
<button
|
||||
@click=${() => {
|
||||
this.setFlag(country.code);
|
||||
this.close();
|
||||
}}
|
||||
class="group relative flex flex-col items-center gap-2 p-3 rounded-xl border border-white/5 bg-white/5 hover:bg-white/10 hover:border-white/20 transition-all cursor-pointer
|
||||
w-[100px] sm:w-[120px]"
|
||||
>
|
||||
<img
|
||||
class="w-full h-auto rounded group-hover:scale-105 transition-transform duration-200 pointer-events-none"
|
||||
draggable="false"
|
||||
src=${assetUrl(`flags/${country.code}.svg`)}
|
||||
loading="lazy"
|
||||
@error=${(e: Event) => {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
const fallback = assetUrl("flags/xx.svg");
|
||||
if (img.src && !img.src.endsWith(fallback)) {
|
||||
img.src = fallback;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
class="text-xs font-bold text-gray-300 group-hover:text-white text-center leading-tight w-full whitespace-normal break-words"
|
||||
>${country.name}</span
|
||||
>
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
${this.renderFlags()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -113,21 +156,18 @@ export class FlagInputModal extends BaseModal {
|
||||
}
|
||||
|
||||
private setFlag(flag: string) {
|
||||
localStorage.setItem("flag", flag);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("flag-change", {
|
||||
detail: { flag },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
new UserSettings().setFlag(flag);
|
||||
}
|
||||
|
||||
protected onOpen(): void {
|
||||
// No custom logic needed
|
||||
protected async onOpen(): Promise<void> {
|
||||
[this.cosmetics, this.userMe] = await Promise.all([
|
||||
fetchCosmetics(),
|
||||
getUserMe().then((r) => r || (false as const)),
|
||||
]);
|
||||
}
|
||||
|
||||
protected onClose(): void {
|
||||
this.search = "";
|
||||
if (this.returnTo) {
|
||||
const returnEl = document.querySelector(this.returnTo) as any;
|
||||
if (returnEl?.open) {
|
||||
|
||||
@@ -227,6 +227,7 @@ export class LangSelector extends LitElement {
|
||||
"o-modal",
|
||||
"o-button",
|
||||
"territory-patterns-modal",
|
||||
"store-modal",
|
||||
"pattern-input",
|
||||
"fluent-slider",
|
||||
"news-modal",
|
||||
|
||||
+31
-38
@@ -41,6 +41,8 @@ import { initNavigation } from "./Navigation";
|
||||
import "./NewsModal";
|
||||
import "./PatternInput";
|
||||
import "./SinglePlayerModal";
|
||||
import { StoreModal } from "./Store";
|
||||
import "./TerritoryPatternsModal";
|
||||
import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
|
||||
import { TokenLoginModal } from "./TokenLoginModal";
|
||||
import {
|
||||
@@ -246,7 +248,7 @@ class Client {
|
||||
private joinModal: JoinLobbyModal;
|
||||
private gameModeSelector: GameModeSelector;
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
private patternsModal: TerritoryPatternsModal;
|
||||
private storeModal: StoreModal;
|
||||
private tokenLoginModal: TokenLoginModal;
|
||||
private matchmakingModal: MatchmakingModal;
|
||||
|
||||
@@ -360,30 +362,22 @@ class Client {
|
||||
});
|
||||
});
|
||||
|
||||
this.patternsModal = document.getElementById(
|
||||
this.storeModal = document.getElementById("page-item-store") as StoreModal;
|
||||
if (!this.storeModal || !(this.storeModal instanceof StoreModal)) {
|
||||
console.warn("Store modal element not found");
|
||||
}
|
||||
|
||||
const patternsModal = document.getElementById(
|
||||
"territory-patterns-modal",
|
||||
) as TerritoryPatternsModal;
|
||||
if (
|
||||
!this.patternsModal ||
|
||||
!(this.patternsModal instanceof TerritoryPatternsModal)
|
||||
) {
|
||||
console.warn("Territory patterns modal element not found");
|
||||
if (!patternsModal || !(patternsModal instanceof TerritoryPatternsModal)) {
|
||||
console.warn("Patterns modal element not found");
|
||||
}
|
||||
|
||||
// Attach listener to any pattern-input component
|
||||
document.querySelectorAll("pattern-input").forEach((patternInput) => {
|
||||
patternInput.addEventListener("pattern-input-click", () => {
|
||||
// Open the Store page which contains the patterns UI
|
||||
window.showPage?.("page-item-store");
|
||||
const skinStoreModal = document.getElementById(
|
||||
"page-item-store",
|
||||
) as HTMLElement & { open?: (opts: any) => void };
|
||||
if (skinStoreModal) {
|
||||
skinStoreModal.classList.remove("hidden");
|
||||
if (typeof skinStoreModal.open === "function") {
|
||||
skinStoreModal.open({ showOnlyOwned: true });
|
||||
}
|
||||
}
|
||||
patternsModal.open();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -392,29 +386,20 @@ class Client {
|
||||
if (mobilePat) mobilePat.style.display = "none";
|
||||
}
|
||||
|
||||
if (
|
||||
!this.patternsModal ||
|
||||
!(this.patternsModal instanceof TerritoryPatternsModal)
|
||||
) {
|
||||
console.warn("Territory patterns modal element not found");
|
||||
if (!this.storeModal || !(this.storeModal instanceof StoreModal)) {
|
||||
console.warn("Store modal element not found");
|
||||
}
|
||||
|
||||
// We no longer need to manually manage the preview button as PatternInput handles it component-side.
|
||||
// However, we still want to ensure the modal can be opened.
|
||||
// The setupPatternInput above handles the click event for the new buttons.
|
||||
|
||||
this.patternsModal.refresh();
|
||||
|
||||
// Listen for pattern selection to update any other listeners if needed,
|
||||
// though PatternInput handles its own updates via window event.
|
||||
this.patternsModal.addEventListener("pattern-selected", () => {
|
||||
// PatternInput components will update themselves.
|
||||
});
|
||||
this.storeModal.refresh();
|
||||
|
||||
window.addEventListener("showPage", (e: any) => {
|
||||
if (typeof e?.detail === "string" && e.detail === "page-play") {
|
||||
setTimeout(() => {
|
||||
this.patternsModal.refresh();
|
||||
this.storeModal.refresh();
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
@@ -646,14 +631,20 @@ class Client {
|
||||
return;
|
||||
}
|
||||
|
||||
const patternName = params.get("cosmetic");
|
||||
if (!patternName) {
|
||||
const cosmeticName = params.get("cosmetic");
|
||||
if (!cosmeticName) {
|
||||
alert("Something went wrong. Please contact support.");
|
||||
console.error("purchase-completed but no pattern name");
|
||||
return;
|
||||
}
|
||||
|
||||
this.userSettings.setSelectedPatternName(patternName);
|
||||
const setCosmetic = () => {
|
||||
if (cosmeticName.startsWith("pattern:")) {
|
||||
this.userSettings.setSelectedPatternName(cosmeticName);
|
||||
} else if (cosmeticName.startsWith("flag:")) {
|
||||
this.userSettings.setFlag(cosmeticName);
|
||||
}
|
||||
};
|
||||
const token = params.get("login-token");
|
||||
|
||||
if (token) {
|
||||
@@ -661,12 +652,13 @@ class Client {
|
||||
window.addEventListener("beforeunload", () => {
|
||||
// The page reloads after token login, so we need to save the pattern name
|
||||
// in case it is unset during reload.
|
||||
this.userSettings.setSelectedPatternName(patternName);
|
||||
setCosmetic();
|
||||
});
|
||||
this.tokenLoginModal.openWithToken(token);
|
||||
} else {
|
||||
alertAndStrip(`purchase succeeded: ${patternName}`);
|
||||
this.patternsModal.refresh();
|
||||
alertAndStrip(`purchase succeeded: ${cosmeticName}`);
|
||||
setCosmetic();
|
||||
this.storeModal.refresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -701,7 +693,7 @@ class Client {
|
||||
const affiliateCode = decodedHash.replace("#affiliate=", "");
|
||||
strip();
|
||||
if (affiliateCode) {
|
||||
this.patternsModal?.open(affiliateCode);
|
||||
this.storeModal?.open(affiliateCode);
|
||||
}
|
||||
}
|
||||
if (decodedHash.startsWith("#refresh")) {
|
||||
@@ -785,6 +777,7 @@ class Client {
|
||||
"user-setting",
|
||||
"troubleshooting-modal",
|
||||
"territory-patterns-modal",
|
||||
"store-modal",
|
||||
"language-modal",
|
||||
"news-modal",
|
||||
"flag-input-modal",
|
||||
|
||||
@@ -46,9 +46,13 @@ export class PatternInput extends LitElement {
|
||||
this.pattern = cosmetics.pattern ?? null;
|
||||
if (!this.isConnected) return;
|
||||
this.isLoading = false;
|
||||
window.addEventListener("pattern-selected", this._onPatternSelected, {
|
||||
signal: this._abortController.signal,
|
||||
});
|
||||
window.addEventListener(
|
||||
"event:user-settings-changed:pattern",
|
||||
this._onPatternSelected,
|
||||
{
|
||||
signal: this._abortController.signal,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
@@ -79,10 +83,10 @@ export class PatternInput extends LitElement {
|
||||
}
|
||||
|
||||
const showSelect = this.showSelectLabel && this.getIsDefaultPattern();
|
||||
this.style.setProperty("height", "3rem");
|
||||
this.style.setProperty("height", "2.5rem");
|
||||
this.style.setProperty(
|
||||
"width",
|
||||
showSelect ? "clamp(6.5rem, 28vw, 9.5rem)" : "3rem",
|
||||
showSelect ? "clamp(3.25rem, 14vw, 4.75rem)" : "2.5rem",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,7 +140,9 @@ export class PatternInput extends LitElement {
|
||||
</span>
|
||||
${showSelect
|
||||
? html`<span
|
||||
class="text-[10px] font-black text-white uppercase leading-none break-words w-full text-center px-1"
|
||||
class="${this.adaptiveSize
|
||||
? "text-[7px] leading-tight px-0.5"
|
||||
: "text-[10px] leading-none break-words px-1"} font-black text-white uppercase w-full text-center"
|
||||
>
|
||||
${translateText("territory_patterns.select_skin")}
|
||||
</span>`
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { ColorPalette, Cosmetics, Pattern } from "../core/CosmeticSchemas";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import { PlayerPattern } from "../core/Schemas";
|
||||
import { hasLinkedAccount } from "./Api";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/FlagButton";
|
||||
import "./components/PatternButton";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import {
|
||||
fetchCosmetics,
|
||||
flagRelationship,
|
||||
getPlayerCosmetics,
|
||||
handlePurchase,
|
||||
patternRelationship,
|
||||
} from "./Cosmetics";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@customElement("store-modal")
|
||||
export class StoreModal extends BaseModal {
|
||||
@state() private selectedPattern: PlayerPattern | null;
|
||||
@state() private selectedColor: string | null = null;
|
||||
@state() private activeTab: "patterns" | "flags" = "patterns";
|
||||
|
||||
private cosmetics: Cosmetics | null = null;
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
private isActive = false;
|
||||
private affiliateCode: string | null = null;
|
||||
private userMeResponse: UserMeResponse | false = false;
|
||||
|
||||
private _onPatternSelected = async () => {
|
||||
await this.updateFromSettings();
|
||||
this.refresh();
|
||||
};
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
document.addEventListener(
|
||||
"userMeResponse",
|
||||
(event: CustomEvent<UserMeResponse | false>) => {
|
||||
this.onUserMe(event.detail);
|
||||
},
|
||||
);
|
||||
window.addEventListener(
|
||||
"event:user-settings-changed:pattern",
|
||||
this._onPatternSelected,
|
||||
);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener(
|
||||
"event:user-settings-changed:pattern",
|
||||
this._onPatternSelected,
|
||||
);
|
||||
}
|
||||
|
||||
private async updateFromSettings() {
|
||||
const cosmetics = await getPlayerCosmetics();
|
||||
this.selectedPattern = cosmetics.pattern ?? null;
|
||||
this.selectedColor = cosmetics.color?.color ?? null;
|
||||
}
|
||||
|
||||
async onUserMe(userMeResponse: UserMeResponse | false) {
|
||||
this.userMeResponse = userMeResponse;
|
||||
this.cosmetics = await fetchCosmetics();
|
||||
await this.updateFromSettings();
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
private renderHeader(): TemplateResult {
|
||||
return html`
|
||||
${modalHeader({
|
||||
title: translateText("store.title"),
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
rightContent: !hasLinkedAccount(this.userMeResponse)
|
||||
? html`<div class="flex items-center">
|
||||
${this.renderNotLoggedInWarning()}
|
||||
</div>`
|
||||
: undefined,
|
||||
})}
|
||||
<div class="flex items-center gap-2 justify-center pt-2">
|
||||
<button
|
||||
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
|
||||
.activeTab === "patterns"
|
||||
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
|
||||
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
|
||||
@click=${() => (this.activeTab = "patterns")}
|
||||
>
|
||||
${translateText("store.patterns")}
|
||||
</button>
|
||||
<button
|
||||
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
|
||||
.activeTab === "flags"
|
||||
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
|
||||
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
|
||||
@click=${() => (this.activeTab = "flags")}
|
||||
>
|
||||
${translateText("store.flags")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPatternGrid(): TemplateResult {
|
||||
const buttons: TemplateResult[] = [];
|
||||
const patterns: (Pattern | null)[] = [
|
||||
null,
|
||||
...Object.values(this.cosmetics?.patterns ?? {}),
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const colorPalettes = pattern
|
||||
? [...(pattern.colorPalettes ?? []), null]
|
||||
: [null];
|
||||
for (const colorPalette of colorPalettes) {
|
||||
let rel = "owned";
|
||||
if (pattern) {
|
||||
rel = patternRelationship(
|
||||
pattern,
|
||||
colorPalette,
|
||||
this.userMeResponse,
|
||||
this.affiliateCode,
|
||||
);
|
||||
}
|
||||
if (rel === "blocked" || rel === "owned") {
|
||||
continue;
|
||||
}
|
||||
const isDefaultPattern = pattern === null;
|
||||
const isSelected =
|
||||
(isDefaultPattern && this.selectedPattern === null) ||
|
||||
(!isDefaultPattern &&
|
||||
this.selectedPattern &&
|
||||
this.selectedPattern.name === pattern?.name &&
|
||||
(this.selectedPattern.colorPalette?.name ?? null) ===
|
||||
(colorPalette?.name ?? null));
|
||||
buttons.push(html`
|
||||
<pattern-button
|
||||
.pattern=${pattern}
|
||||
.colorPalette=${this.cosmetics?.colorPalettes?.[
|
||||
colorPalette?.name ?? ""
|
||||
] ?? null}
|
||||
.requiresPurchase=${rel === "purchasable"}
|
||||
.selected=${isSelected}
|
||||
.onSelect=${(p: PlayerPattern | null) => this.selectPattern(p)}
|
||||
.onPurchase=${(p: Pattern, cp: ColorPalette | null) =>
|
||||
handlePurchase(p.product!, cp?.name)}
|
||||
></pattern-button>
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
if (buttons.length === 0) {
|
||||
return html`<div
|
||||
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
|
||||
>
|
||||
${translateText("store.no_skins")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-wrap gap-4 p-2 justify-center items-stretch content-start"
|
||||
>
|
||||
${buttons}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFlagGrid(): TemplateResult {
|
||||
const buttons: TemplateResult[] = [];
|
||||
const flags = Object.entries(this.cosmetics?.flags ?? {});
|
||||
for (const [key, flag] of flags) {
|
||||
const rel = flagRelationship(
|
||||
flag,
|
||||
this.userMeResponse,
|
||||
this.affiliateCode,
|
||||
);
|
||||
if (rel === "blocked" || rel === "owned") continue;
|
||||
const selectedFlag = new UserSettings().getFlag() ?? "";
|
||||
buttons.push(html`
|
||||
<flag-button
|
||||
.flag=${{
|
||||
key: `flag:${key}`,
|
||||
name: flag.name,
|
||||
url: flag.url,
|
||||
product: flag.product,
|
||||
artist: flag.artist,
|
||||
}}
|
||||
.selected=${selectedFlag === `flag:${key}`}
|
||||
.requiresPurchase=${rel === "purchasable"}
|
||||
.onPurchase=${() => handlePurchase(flag.product!)}
|
||||
></flag-button>
|
||||
`);
|
||||
}
|
||||
|
||||
if (buttons.length === 0) {
|
||||
return html`<div
|
||||
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
|
||||
>
|
||||
${translateText("store.no_flags")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-wrap gap-4 p-2 justify-center items-stretch content-start"
|
||||
>
|
||||
${buttons}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderNotLoggedInWarning(): TemplateResult {
|
||||
return html`<button
|
||||
class="px-4 py-2 text-xs font-bold uppercase tracking-wider transition-colors duration-200 rounded-lg bg-red-500/20 text-red-400 border border-red-500/30 cursor-pointer hover:bg-red-500/30"
|
||||
@click=${() => {
|
||||
this.close();
|
||||
window.showPage?.("page-account");
|
||||
}}
|
||||
>
|
||||
${translateText("territory_patterns.not_logged_in")}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.isActive && !this.inline) return html``;
|
||||
|
||||
const content = html`
|
||||
<div class="${this.modalContainerClass}">
|
||||
${this.renderHeader()}
|
||||
<div class="overflow-y-auto pr-2 custom-scrollbar mr-1">
|
||||
${this.activeTab === "patterns"
|
||||
? this.renderPatternGrid()
|
||||
: this.renderFlagGrid()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.inline) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return html`
|
||||
<o-modal
|
||||
id="storeModal"
|
||||
title="${translateText("store.title")}"
|
||||
?inline=${this.inline}
|
||||
?hideHeader=${true}
|
||||
?hideCloseButton=${true}
|
||||
>
|
||||
${content}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
public async open(options?: string | { affiliateCode?: string }) {
|
||||
if (this.isModalOpen) return;
|
||||
this.isActive = true;
|
||||
if (typeof options === "string") {
|
||||
this.affiliateCode = options;
|
||||
} else if (
|
||||
options !== null &&
|
||||
typeof options === "object" &&
|
||||
!Array.isArray(options)
|
||||
) {
|
||||
this.affiliateCode = options.affiliateCode ?? null;
|
||||
} else {
|
||||
this.affiliateCode = null;
|
||||
}
|
||||
|
||||
this.cosmetics ??= await fetchCosmetics();
|
||||
await this.refresh();
|
||||
super.open();
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.isActive = false;
|
||||
this.affiliateCode = null;
|
||||
super.close();
|
||||
}
|
||||
|
||||
private selectPattern(pattern: PlayerPattern | null) {
|
||||
this.selectedColor = null;
|
||||
this.userSettings.setSelectedColor(undefined);
|
||||
if (pattern === null) {
|
||||
this.userSettings.setSelectedPatternName(undefined);
|
||||
} else {
|
||||
const name =
|
||||
pattern.colorPalette?.name === undefined
|
||||
? pattern.name
|
||||
: `${pattern.name}:${pattern.colorPalette.name}`;
|
||||
this.userSettings.setSelectedPatternName(`pattern:${name}`);
|
||||
}
|
||||
this.selectedPattern = pattern;
|
||||
this.refresh();
|
||||
this.showSelectedPopup(pattern);
|
||||
this.close();
|
||||
}
|
||||
|
||||
private showSelectedPopup(pattern: PlayerPattern | null) {
|
||||
let skinName = translateText("territory_patterns.pattern.default");
|
||||
if (pattern && pattern.name) {
|
||||
skinName = pattern.name
|
||||
.split("_")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ");
|
||||
if (pattern.colorPalette && pattern.colorPalette.name) {
|
||||
skinName += ` (${pattern.colorPalette.name})`;
|
||||
}
|
||||
}
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: `${skinName} ${translateText("territory_patterns.selected")}`,
|
||||
duration: 2000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async refresh() {
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,16 @@ import type { TemplateResult } from "lit";
|
||||
import { html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { ColorPalette, Cosmetics, Pattern } from "../core/CosmeticSchemas";
|
||||
import { Cosmetics, Pattern } from "../core/CosmeticSchemas";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import { PlayerPattern } from "../core/Schemas";
|
||||
import { hasLinkedAccount } from "./Api";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/Difficulties";
|
||||
import "./components/PatternButton";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import {
|
||||
fetchCosmetics,
|
||||
getPlayerCosmetics,
|
||||
handlePurchase,
|
||||
patternRelationship,
|
||||
} from "./Cosmetics";
|
||||
import { translateText } from "./Utils";
|
||||
@@ -25,17 +23,8 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
@state() private selectedPattern: PlayerPattern | null;
|
||||
@state() private selectedColor: string | null = null;
|
||||
|
||||
@state() private activeTab: "patterns" | "colors" = "patterns";
|
||||
@state() private showOnlyOwned: boolean = false;
|
||||
|
||||
private cosmetics: Cosmetics | null = null;
|
||||
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
|
||||
private isActive = false;
|
||||
|
||||
private affiliateCode: string | null = null;
|
||||
|
||||
private userMeResponse: UserMeResponse | false = false;
|
||||
|
||||
private _onPatternSelected = async () => {
|
||||
@@ -43,10 +32,6 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
this.refresh();
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
document.addEventListener(
|
||||
@@ -55,12 +40,18 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
this.onUserMe(event.detail);
|
||||
},
|
||||
);
|
||||
window.addEventListener("pattern-selected", this._onPatternSelected);
|
||||
window.addEventListener(
|
||||
"event:user-settings-changed:pattern",
|
||||
this._onPatternSelected,
|
||||
);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("pattern-selected", this._onPatternSelected);
|
||||
window.removeEventListener(
|
||||
"event:user-settings-changed:pattern",
|
||||
this._onPatternSelected,
|
||||
);
|
||||
}
|
||||
|
||||
private async updateFromSettings() {
|
||||
@@ -76,42 +67,6 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
private renderTabNavigation(): TemplateResult {
|
||||
return html`
|
||||
${modalHeader({
|
||||
title: translateText("territory_patterns.title"),
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
rightContent: !hasLinkedAccount(this.userMeResponse)
|
||||
? html`<div class="flex items-center">
|
||||
${this.renderNotLoggedInWarning()}
|
||||
</div>`
|
||||
: undefined,
|
||||
})}
|
||||
<!-- TEMP DISABlE TAB SWITCHING
|
||||
<div class="flex items-center gap-2 justify-center">
|
||||
<button
|
||||
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
|
||||
.activeTab === "patterns"
|
||||
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
|
||||
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
|
||||
@click=${() => (this.activeTab = "patterns")}
|
||||
>
|
||||
${translateText("territory_patterns.title")}
|
||||
</button>
|
||||
<button
|
||||
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
|
||||
.activeTab === "colors"
|
||||
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
|
||||
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
|
||||
@click=${() => (this.activeTab = "colors")}
|
||||
>
|
||||
${translateText("territory_patterns.colors")}
|
||||
</button>
|
||||
TEMP DISABlE TAB SWITCHING -->
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPatternGrid(): TemplateResult {
|
||||
const buttons: TemplateResult[] = [];
|
||||
const patterns: (Pattern | null)[] = [
|
||||
@@ -129,19 +84,12 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
pattern,
|
||||
colorPalette,
|
||||
this.userMeResponse,
|
||||
this.affiliateCode,
|
||||
null,
|
||||
);
|
||||
}
|
||||
if (rel === "blocked") {
|
||||
if (rel !== "owned") {
|
||||
continue;
|
||||
}
|
||||
if (this.showOnlyOwned) {
|
||||
if (rel !== "owned") continue;
|
||||
} else {
|
||||
// Store mode: hide owned items
|
||||
if (rel === "owned") continue;
|
||||
}
|
||||
// Determine if this pattern/color is selected
|
||||
const isDefaultPattern = pattern === null;
|
||||
const isSelected =
|
||||
(isDefaultPattern && this.selectedPattern === null) ||
|
||||
@@ -156,11 +104,9 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
.colorPalette=${this.cosmetics?.colorPalettes?.[
|
||||
colorPalette?.name ?? ""
|
||||
] ?? null}
|
||||
.requiresPurchase=${rel === "purchasable"}
|
||||
.requiresPurchase=${false}
|
||||
.selected=${isSelected}
|
||||
.onSelect=${(p: PlayerPattern | null) => this.selectPattern(p)}
|
||||
.onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) =>
|
||||
handlePurchase(p, colorPalette)}
|
||||
></pattern-button>
|
||||
`);
|
||||
}
|
||||
@@ -168,42 +114,15 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
|
||||
return html`
|
||||
<div class="flex flex-col">
|
||||
<div class="pt-4 flex justify-center">
|
||||
${hasLinkedAccount(this.userMeResponse)
|
||||
? this.renderMySkinsButton()
|
||||
: html``}
|
||||
<div
|
||||
class="flex flex-wrap gap-4 p-2 justify-center items-stretch content-start"
|
||||
>
|
||||
${buttons}
|
||||
</div>
|
||||
${!this.showOnlyOwned && buttons.length === 0
|
||||
? html`<div
|
||||
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
|
||||
>
|
||||
${translateText("territory_patterns.all_owned")}
|
||||
</div>`
|
||||
: html`
|
||||
<div
|
||||
class="flex flex-wrap gap-4 p-2 justify-center items-stretch content-start"
|
||||
>
|
||||
${buttons}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMySkinsButton(): TemplateResult {
|
||||
return html`<button
|
||||
class="px-4 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-wider border mb-4 ${this
|
||||
.showOnlyOwned
|
||||
? "bg-blue-500/20 text-blue-400 border-blue-500/50 shadow-[0_0_10px_rgba(59,130,246,0.3)]"
|
||||
: "bg-white/5 text-white/60 border-white/10 hover:bg-white/10 hover:text-white"}"
|
||||
@click=${() => {
|
||||
this.showOnlyOwned = !this.showOnlyOwned;
|
||||
}}
|
||||
>
|
||||
${translateText("territory_patterns.show_only_owned")}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
private renderNotLoggedInWarning(): TemplateResult {
|
||||
return html`<button
|
||||
class="px-4 py-2 text-xs font-bold uppercase tracking-wider transition-colors duration-200 rounded-lg bg-red-500/20 text-red-400 border border-red-500/30 cursor-pointer hover:bg-red-500/30"
|
||||
@@ -216,44 +135,27 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
</button>`;
|
||||
}
|
||||
|
||||
private renderColorSwatchGrid(): TemplateResult {
|
||||
const hexCodes = (
|
||||
this.userMeResponse === false
|
||||
? []
|
||||
: (this.userMeResponse.player.flares ?? [])
|
||||
)
|
||||
.filter((flare) => flare.startsWith("color:"))
|
||||
.map((flare) => flare.split(":")[1]);
|
||||
return html`
|
||||
<div class="flex flex-wrap gap-3 p-2 justify-center items-center">
|
||||
${hexCodes.map(
|
||||
(hexCode) => html`
|
||||
<div
|
||||
class="w-12 h-12 rounded-xl border-2 border-white/10 cursor-pointer transition-all duration-200 hover:scale-110 hover:shadow-[0_0_15px_rgba(255,255,255,0.3)] hover:border-white relative group"
|
||||
style="background-color: ${hexCode};"
|
||||
title="${hexCode}"
|
||||
@click=${() => this.selectColor(hexCode)}
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 rounded-xl ring-2 ring-inset ring-black/20"
|
||||
></div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.isActive && !this.inline) return html``;
|
||||
|
||||
const content = html`
|
||||
<div class="${this.modalContainerClass}">
|
||||
${this.renderTabNavigation()}
|
||||
<div class="overflow-y-auto pr-2 custom-scrollbar mr-1">
|
||||
${this.activeTab === "patterns"
|
||||
? this.renderPatternGrid()
|
||||
: this.renderColorSwatchGrid()}
|
||||
<div
|
||||
class="relative flex flex-col border-b border-white/10 pb-4 shrink-0"
|
||||
>
|
||||
${modalHeader({
|
||||
title: translateText("territory_patterns.title"),
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
rightContent: !hasLinkedAccount(this.userMeResponse)
|
||||
? html`<div class="flex items-center">
|
||||
${this.renderNotLoggedInWarning()}
|
||||
</div>`
|
||||
: undefined,
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
class="flex-1 overflow-y-auto px-3 pb-3 scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent mr-1"
|
||||
>
|
||||
${this.renderPatternGrid()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -265,9 +167,7 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
return html`
|
||||
<o-modal
|
||||
id="territoryPatternsModal"
|
||||
title="${this.activeTab === "patterns"
|
||||
? translateText("territory_patterns.title")
|
||||
: translateText("territory_patterns.colors")}"
|
||||
title="${translateText("territory_patterns.title")}"
|
||||
?inline=${this.inline}
|
||||
?hideHeader=${true}
|
||||
?hideCloseButton=${true}
|
||||
@@ -277,33 +177,8 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
`;
|
||||
}
|
||||
|
||||
public async open(
|
||||
options?: string | { affiliateCode?: string; showOnlyOwned?: boolean },
|
||||
) {
|
||||
this.isActive = true;
|
||||
if (typeof options === "string") {
|
||||
this.affiliateCode = options;
|
||||
this.showOnlyOwned = false;
|
||||
} else if (
|
||||
options !== null &&
|
||||
typeof options === "object" &&
|
||||
!Array.isArray(options)
|
||||
) {
|
||||
this.affiliateCode = options.affiliateCode ?? null;
|
||||
this.showOnlyOwned = options.showOnlyOwned ?? false;
|
||||
} else {
|
||||
this.affiliateCode = null;
|
||||
this.showOnlyOwned = false;
|
||||
}
|
||||
|
||||
protected async onOpen(): Promise<void> {
|
||||
await this.refresh();
|
||||
super.open();
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.isActive = false;
|
||||
this.affiliateCode = null;
|
||||
super.close();
|
||||
}
|
||||
|
||||
private selectPattern(pattern: PlayerPattern | null) {
|
||||
@@ -320,16 +195,11 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
}
|
||||
this.selectedPattern = pattern;
|
||||
this.refresh();
|
||||
// Dispatch event so Main.ts can refresh the preview button
|
||||
this.dispatchEvent(new CustomEvent("pattern-selected", { bubbles: true }));
|
||||
// Show popup/modal for skin selection
|
||||
this.showSkinSelectedPopup();
|
||||
// Close the skin store
|
||||
this.close();
|
||||
}
|
||||
|
||||
private showSkinSelectedPopup() {
|
||||
// Use unified heads-up-message for feedback
|
||||
let skinName = translateText("territory_patterns.pattern.default");
|
||||
if (this.selectedPattern && this.selectedPattern.name) {
|
||||
skinName = this.selectedPattern.name
|
||||
@@ -353,29 +223,6 @@ export class TerritoryPatternsModal extends BaseModal {
|
||||
);
|
||||
}
|
||||
|
||||
private selectColor(hexCode: string) {
|
||||
this.selectedPattern = null;
|
||||
this.userSettings.setSelectedPatternName(undefined);
|
||||
this.selectedColor = hexCode;
|
||||
this.userSettings.setSelectedColor(hexCode);
|
||||
this.refresh();
|
||||
this.dispatchEvent(new CustomEvent("pattern-selected", { bubbles: true }));
|
||||
this.close();
|
||||
}
|
||||
|
||||
private renderColorPreview(
|
||||
hexCode: string,
|
||||
width: number,
|
||||
height: number,
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<div
|
||||
class="w-full h-full rounded"
|
||||
style="background-color: ${hexCode};"
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
|
||||
public async refresh() {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
@@ -10,14 +10,8 @@ import "./components/baseComponents/setting/SettingSlider";
|
||||
import "./components/baseComponents/setting/SettingToggle";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import "./FlagInputModal";
|
||||
import { Platform } from "./Platform";
|
||||
|
||||
interface FlagInputModalElement extends HTMLElement {
|
||||
open(): void;
|
||||
returnTo?: string;
|
||||
}
|
||||
|
||||
const isMac = Platform.isMac;
|
||||
|
||||
const DefaultKeybinds: Record<string, string> = {
|
||||
@@ -399,16 +393,6 @@ export class UserSettingModal extends BaseModal {
|
||||
this.userSettings.set("settings.performanceOverlay", enabled);
|
||||
}
|
||||
|
||||
private openFlagSelector = () => {
|
||||
const flagInputModal =
|
||||
document.querySelector<FlagInputModalElement>("#flag-input-modal");
|
||||
if (flagInputModal?.open) {
|
||||
this.close();
|
||||
flagInputModal.returnTo = "#" + (this.id || "page-settings");
|
||||
flagInputModal.open();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const activeContent =
|
||||
this.activeTab === "basic"
|
||||
@@ -819,35 +803,6 @@ export class UserSettingModal extends BaseModal {
|
||||
|
||||
private renderBasicSettings() {
|
||||
return html`
|
||||
<!-- 🚩 Flag Selector -->
|
||||
<div
|
||||
class="flex flex-row items-center justify-between w-full p-4 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition-all gap-4 cursor-pointer"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click=${this.openFlagSelector}
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
this.openFlagSelector();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col flex-1 min-w-0 mr-4">
|
||||
<div class="text-white font-bold text-base block mb-1">
|
||||
${translateText("flag_input.title")}
|
||||
</div>
|
||||
<div class="text-white/50 text-sm leading-snug">
|
||||
${translateText("flag_input.button_title")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative inline-block w-12 h-8 shrink-0 rounded overflow-hidden border border-white/20"
|
||||
>
|
||||
<flag-input class="w-full h-full pointer-events-none"></flag-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 🌙 Dark Mode -->
|
||||
<setting-toggle
|
||||
label="${translateText("user_setting.dark_mode_label")}"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { translateText } from "../Utils";
|
||||
|
||||
@customElement("artist-info")
|
||||
export class ArtistInfo extends LitElement {
|
||||
@property({ type: String })
|
||||
artist?: string;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.artist) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="absolute -top-1 -right-1 z-10 group/artist"
|
||||
@click=${(e: Event) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
class="w-6 h-6 rounded-full bg-white/20 hover:bg-white/40 flex items-center justify-center cursor-help transition-colors duration-150"
|
||||
>
|
||||
<span class="text-xs font-bold text-white/70">?</span>
|
||||
</div>
|
||||
<div
|
||||
class="hidden group-hover/artist:block absolute top-7 right-0 bg-zinc-800 text-white text-xs px-2.5 py-1.5 rounded shadow-lg whitespace-nowrap z-20 border border-white/10"
|
||||
>
|
||||
${translateText("cosmetics.artist_label")} ${this.artist}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { Product } from "../../core/CosmeticSchemas";
|
||||
import { translateCosmetic } from "../Cosmetics";
|
||||
import "./ArtistInfo";
|
||||
import "./PurchaseButton";
|
||||
|
||||
export interface FlagItem {
|
||||
key: string;
|
||||
name: string;
|
||||
url: string;
|
||||
product?: Product | null;
|
||||
artist?: string;
|
||||
}
|
||||
|
||||
@customElement("flag-button")
|
||||
export class FlagButton extends LitElement {
|
||||
@property({ type: Boolean })
|
||||
selected: boolean = false;
|
||||
|
||||
@property({ type: Object })
|
||||
flag!: FlagItem;
|
||||
|
||||
@property({ type: Boolean })
|
||||
requiresPurchase: boolean = false;
|
||||
|
||||
@property({ type: Function })
|
||||
onSelect?: (flagKey: string) => void;
|
||||
|
||||
@property({ type: Function })
|
||||
onPurchase?: () => void;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
this.onSelect?.(this.flag.key);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-col items-center justify-between gap-1 p-1.5 bg-white/5 backdrop-blur-sm border rounded-lg w-36 h-full transition-all duration-200 ${this
|
||||
.selected
|
||||
? "border-green-500 shadow-[0_0_15px_rgba(34,197,94,0.5)]"
|
||||
: "hover:bg-white/10 hover:border-white/20 hover:shadow-xl border-white/10"}"
|
||||
>
|
||||
<button
|
||||
class="group relative flex flex-col items-center w-full gap-1 rounded-lg cursor-pointer transition-all duration-200
|
||||
disabled:cursor-not-allowed flex-1"
|
||||
?disabled=${this.requiresPurchase}
|
||||
@click=${this.handleClick}
|
||||
>
|
||||
<artist-info .artist=${this.flag.artist}></artist-info>
|
||||
<div
|
||||
class="text-[10px] font-bold text-white uppercase tracking-wider mt-1 ${this
|
||||
.flag.artist
|
||||
? "pr-5"
|
||||
: ""} text-center truncate w-full ${this.requiresPurchase
|
||||
? "opacity-50"
|
||||
: ""}"
|
||||
title="${translateCosmetic("flags", this.flag.name)}"
|
||||
>
|
||||
${translateCosmetic("flags", this.flag.name)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-full aspect-square flex items-center justify-center bg-white/5 rounded-lg p-2 border border-white/10 group-hover:border-white/20 transition-colors duration-200 overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src=${this.flag.url}
|
||||
alt=${this.flag.name}
|
||||
class="w-full h-full object-contain pointer-events-none"
|
||||
draggable="false"
|
||||
loading="lazy"
|
||||
@error=${(e: Event) => {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
const fallback = "/flags/xx.svg";
|
||||
if (img.src && !img.src.endsWith(fallback)) {
|
||||
img.src = fallback;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
${this.requiresPurchase && this.flag.product
|
||||
? html`
|
||||
<purchase-button
|
||||
.product=${this.flag.product}
|
||||
.onPurchase=${() => this.onPurchase?.()}
|
||||
></purchase-button>
|
||||
`
|
||||
: null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
} from "../../core/CosmeticSchemas";
|
||||
import { PatternDecoder } from "../../core/PatternDecoder";
|
||||
import { PlayerPattern } from "../../core/Schemas";
|
||||
import { translateCosmetic } from "../Cosmetics";
|
||||
import { translateText } from "../Utils";
|
||||
import "./ArtistInfo";
|
||||
import "./PurchaseButton";
|
||||
|
||||
export const BUTTON_WIDTH = 150;
|
||||
|
||||
@@ -36,18 +39,6 @@ export class PatternButton extends LitElement {
|
||||
return this;
|
||||
}
|
||||
|
||||
private translateCosmetic(prefix: string, patternName: string): string {
|
||||
const translation = translateText(`${prefix}.${patternName}`);
|
||||
if (translation.startsWith(prefix)) {
|
||||
return patternName
|
||||
.split("_")
|
||||
.filter((word) => word.length > 0)
|
||||
.map((word) => word[0].toUpperCase() + word.substring(1))
|
||||
.join(" ");
|
||||
}
|
||||
return translation;
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
if (this.pattern === null) {
|
||||
this.onSelect?.(null);
|
||||
@@ -60,8 +51,7 @@ export class PatternButton extends LitElement {
|
||||
} satisfies PlayerPattern);
|
||||
}
|
||||
|
||||
private handlePurchase(e: Event) {
|
||||
e.stopPropagation();
|
||||
private handlePurchase() {
|
||||
if (this.pattern?.product) {
|
||||
this.onPurchase?.(this.pattern, this.colorPalette ?? null);
|
||||
}
|
||||
@@ -83,22 +73,25 @@ export class PatternButton extends LitElement {
|
||||
?disabled=${this.requiresPurchase}
|
||||
@click=${this.handleClick}
|
||||
>
|
||||
<artist-info .artist=${this.pattern?.artist}></artist-info>
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<div
|
||||
class="text-xs font-bold text-white uppercase tracking-wider mb-1 text-center truncate w-full ${this
|
||||
.requiresPurchase
|
||||
class="text-xs font-bold text-white uppercase tracking-wider mb-1 ${this
|
||||
.pattern?.artist
|
||||
? "pr-5"
|
||||
: ""} text-center truncate w-full ${this.requiresPurchase
|
||||
? "opacity-50"
|
||||
: ""}"
|
||||
title="${isDefaultPattern
|
||||
? translateText("territory_patterns.pattern.default")
|
||||
: this.translateCosmetic(
|
||||
: translateCosmetic(
|
||||
"territory_patterns.pattern",
|
||||
this.pattern!.name,
|
||||
)}"
|
||||
>
|
||||
${isDefaultPattern
|
||||
? translateText("territory_patterns.pattern.default")
|
||||
: this.translateCosmetic(
|
||||
: translateCosmetic(
|
||||
"territory_patterns.pattern",
|
||||
this.pattern!.name,
|
||||
)}
|
||||
@@ -111,7 +104,7 @@ export class PatternButton extends LitElement {
|
||||
? "opacity-50"
|
||||
: ""}"
|
||||
>
|
||||
${this.translateCosmetic(
|
||||
${translateCosmetic(
|
||||
"territory_patterns.color_palette",
|
||||
this.colorPalette!.name,
|
||||
)}
|
||||
@@ -139,18 +132,10 @@ export class PatternButton extends LitElement {
|
||||
|
||||
${this.requiresPurchase && this.pattern?.product
|
||||
? html`
|
||||
<div class="w-full mt-2">
|
||||
<button
|
||||
class="w-full px-4 py-2 bg-green-500/20 text-green-400 border border-green-500/30 rounded-lg text-xs font-bold uppercase tracking-wider cursor-pointer transition-all duration-200
|
||||
hover:bg-green-500/30 hover:shadow-[0_0_15px_rgba(74,222,128,0.2)]"
|
||||
@click=${this.handlePurchase}
|
||||
>
|
||||
${translateText("territory_patterns.purchase")}
|
||||
<span class="ml-1 text-white/60"
|
||||
>(${this.pattern.product.price})</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
<purchase-button
|
||||
.product=${this.pattern.product}
|
||||
.onPurchase=${() => this.handlePurchase()}
|
||||
></purchase-button>
|
||||
`
|
||||
: null}
|
||||
</div>
|
||||
|
||||
@@ -121,6 +121,11 @@ export class PlayPage extends LitElement {
|
||||
adaptive-size
|
||||
class="shrink-0 lg:hidden"
|
||||
></pattern-input>
|
||||
<flag-input
|
||||
id="flag-input-mobile"
|
||||
show-select-label
|
||||
class="shrink-0 lg:hidden h-10 w-10"
|
||||
></flag-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { Product } from "../../core/CosmeticSchemas";
|
||||
import { translateText } from "../Utils";
|
||||
|
||||
@customElement("purchase-button")
|
||||
export class PurchaseButton extends LitElement {
|
||||
@property({ type: Object })
|
||||
product!: Product;
|
||||
|
||||
@property({ type: Function })
|
||||
onPurchase?: () => void;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private handleClick(e: Event) {
|
||||
e.stopPropagation();
|
||||
this.onPurchase?.();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="no-crazygames w-full mt-2">
|
||||
<button
|
||||
class="w-full px-4 py-2 bg-green-500/20 text-green-400 border border-green-500/30 rounded-lg text-xs font-bold uppercase tracking-wider cursor-pointer transition-all duration-200
|
||||
hover:bg-green-500/30 hover:shadow-[0_0_15px_rgba(74,222,128,0.2)]"
|
||||
@click=${this.handleClick}
|
||||
>
|
||||
${translateText("territory_patterns.purchase")}
|
||||
<span class="ml-1 text-white/60">(${this.product.price})</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { assetUrl } from "../../../core/AssetUrls";
|
||||
import { renderPlayerFlag } from "../../../core/CustomFlag";
|
||||
import { assetUrl } from "src/core/AssetUrls";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { PseudoRandom } from "../../../core/PseudoRandom";
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
@@ -217,22 +216,15 @@ export class NameLayer implements Layer {
|
||||
element.classList.add(PLAYER_FLAG);
|
||||
element.style.opacity = "0.8";
|
||||
element.style.zIndex = "1";
|
||||
element.style.aspectRatio = "3/4";
|
||||
element.style.objectFit = "contain";
|
||||
};
|
||||
|
||||
if (player.cosmetics.flag) {
|
||||
const flag = player.cosmetics.flag;
|
||||
if (flag !== undefined && flag !== null && flag.startsWith("!")) {
|
||||
const flagWrapper = document.createElement("div");
|
||||
applyFlagStyles(flagWrapper);
|
||||
renderPlayerFlag(flag, flagWrapper);
|
||||
nameDiv.appendChild(flagWrapper);
|
||||
} else if (flag !== undefined && flag !== null) {
|
||||
const flagImg = document.createElement("img");
|
||||
applyFlagStyles(flagImg);
|
||||
flagImg.src = assetUrl(`flags/${flag}.svg`);
|
||||
nameDiv.appendChild(flagImg);
|
||||
}
|
||||
const flag = assetUrl(player.cosmetics.flag);
|
||||
const flagImg = document.createElement("img");
|
||||
flagImg.src = flag;
|
||||
applyFlagStyles(flagImg);
|
||||
nameDiv.appendChild(flagImg);
|
||||
}
|
||||
nameDiv.classList.add(PLAYER_NAME);
|
||||
nameDiv.style.color = this.theme.textColor(player);
|
||||
|
||||
@@ -476,14 +476,8 @@ export class PerformanceOverlay extends LitElement implements Layer {
|
||||
this.updateTickMetrics(event.tickExecutionDuration, event.tickDelay);
|
||||
};
|
||||
|
||||
private onUserSettingsChanged = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{
|
||||
key?: string;
|
||||
value?: unknown;
|
||||
}>;
|
||||
if (customEvent.detail?.key !== "settings.performanceOverlay") return;
|
||||
|
||||
const nextVisible = customEvent.detail.value === true;
|
||||
private onUserSettingsChanged = (event: CustomEvent) => {
|
||||
const nextVisible = (event.detail as boolean) === true;
|
||||
if (this.isVisible === nextVisible) return;
|
||||
this.setVisible(nextVisible);
|
||||
};
|
||||
@@ -511,7 +505,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
|
||||
|
||||
if (!this.isUserSettingsListenerAttached) {
|
||||
globalThis.addEventListener(
|
||||
"user-settings-changed",
|
||||
"event:user-settings-changed:settings.performanceOverlay",
|
||||
this.onUserSettingsChanged,
|
||||
);
|
||||
this.isUserSettingsListenerAttached = true;
|
||||
@@ -523,7 +517,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
|
||||
|
||||
if (this.isUserSettingsListenerAttached) {
|
||||
globalThis.removeEventListener(
|
||||
"user-settings-changed",
|
||||
"event:user-settings-changed:settings.performanceOverlay",
|
||||
this.onUserSettingsChanged,
|
||||
);
|
||||
this.isUserSettingsListenerAttached = false;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { LitElement, TemplateResult, html } from "lit";
|
||||
import { ref } from "lit-html/directives/ref.js";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { assetUrl } from "../../../core/AssetUrls";
|
||||
import { renderPlayerFlag } from "../../../core/CustomFlag";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import {
|
||||
PlayerProfile,
|
||||
@@ -365,21 +363,10 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
)}"
|
||||
>
|
||||
${player.cosmetics.flag
|
||||
? player.cosmetics.flag!.startsWith("!")
|
||||
? html`<div
|
||||
class="h-6 aspect-3/4 player-flag"
|
||||
${ref((el) => {
|
||||
if (el instanceof HTMLElement) {
|
||||
requestAnimationFrame(() => {
|
||||
renderPlayerFlag(player.cosmetics.flag!, el);
|
||||
});
|
||||
}
|
||||
})}
|
||||
></div>`
|
||||
: html`<img
|
||||
class="h-6 aspect-3/4"
|
||||
src=${assetUrl(`flags/${player.cosmetics.flag!}.svg`)}
|
||||
/>`
|
||||
? html`<img
|
||||
class="h-6 object-contain"
|
||||
src=${assetUrl(player.cosmetics.flag!)}
|
||||
/>`
|
||||
: html``}
|
||||
<span>${player.displayName()}</span>
|
||||
${playerTeam !== "" && player.type() !== PlayerType.Bot
|
||||
|
||||
@@ -203,7 +203,7 @@ export class WinModal extends LitElement implements Layer {
|
||||
.requiresPurchase=${true}
|
||||
.onSelect=${(p: Pattern | null) => {}}
|
||||
.onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) =>
|
||||
handlePurchase(p, colorPalette)}
|
||||
handlePurchase(p.product!, colorPalette?.name)}
|
||||
></pattern-button>
|
||||
`,
|
||||
)}
|
||||
|
||||
@@ -44,10 +44,18 @@ export function normalizeAssetPath(path: string): string {
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
function isAbsoluteUrl(path: string): boolean {
|
||||
return /^https?:\/\//i.test(path);
|
||||
}
|
||||
|
||||
export function buildAssetUrl(
|
||||
path: string,
|
||||
assetManifest: AssetManifest = {},
|
||||
): string {
|
||||
if (isAbsoluteUrl(path)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const normalizedPath = normalizeAssetPath(path);
|
||||
|
||||
const directUrl = assetManifest[normalizedPath];
|
||||
|
||||
+14
-22
@@ -5,7 +5,8 @@ import { PlayerPattern } from "./Schemas";
|
||||
|
||||
export type Cosmetics = z.infer<typeof CosmeticsSchema>;
|
||||
export type Pattern = z.infer<typeof PatternSchema>;
|
||||
export type PatternName = z.infer<typeof PatternNameSchema>;
|
||||
export type Flag = z.infer<typeof FlagSchema>;
|
||||
export type PatternName = z.infer<typeof CosmeticNameSchema>;
|
||||
export type Product = z.infer<typeof ProductSchema>;
|
||||
export type ColorPalette = z.infer<typeof ColorPaletteSchema>;
|
||||
export type PatternData = z.infer<typeof PatternDataSchema>;
|
||||
@@ -16,7 +17,7 @@ export const ProductSchema = z.object({
|
||||
price: z.string(),
|
||||
});
|
||||
|
||||
export const PatternNameSchema = z
|
||||
export const CosmeticNameSchema = z
|
||||
.string()
|
||||
.regex(/^[a-z0-9_]+$/)
|
||||
.max(32);
|
||||
@@ -51,7 +52,7 @@ export const ColorPaletteSchema = z.object({
|
||||
});
|
||||
|
||||
export const PatternSchema = z.object({
|
||||
name: PatternNameSchema,
|
||||
name: CosmeticNameSchema,
|
||||
pattern: PatternDataSchema,
|
||||
colorPalettes: z
|
||||
.object({
|
||||
@@ -62,31 +63,22 @@ export const PatternSchema = z.object({
|
||||
.optional(),
|
||||
affiliateCode: z.string().nullable(),
|
||||
product: ProductSchema.nullable(),
|
||||
artist: z.string().optional(),
|
||||
});
|
||||
|
||||
export const FlagSchema = z.object({
|
||||
name: CosmeticNameSchema,
|
||||
url: z.string(),
|
||||
affiliateCode: z.string().nullable(),
|
||||
product: ProductSchema.nullable(),
|
||||
artist: z.string().optional(),
|
||||
});
|
||||
|
||||
// Schema for resources/cosmetics/cosmetics.json
|
||||
export const CosmeticsSchema = z.object({
|
||||
colorPalettes: z.record(z.string(), ColorPaletteSchema).optional(),
|
||||
patterns: z.record(z.string(), PatternSchema),
|
||||
flag: z
|
||||
.object({
|
||||
layers: z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
name: z.string(),
|
||||
flares: z.array(z.string()).optional(),
|
||||
}),
|
||||
),
|
||||
color: z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
color: z.string(),
|
||||
name: z.string(),
|
||||
flares: z.array(z.string()).optional(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.optional(),
|
||||
flags: z.record(z.string(), FlagSchema),
|
||||
});
|
||||
|
||||
export const DefaultPattern = {
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { assetUrl } from "./AssetUrls";
|
||||
import { Cosmetics } from "./CosmeticSchemas";
|
||||
|
||||
const ANIMATION_DURATIONS: Record<string, number> = {
|
||||
rainbow: 4000,
|
||||
"bright-rainbow": 4000,
|
||||
"copper-glow": 3000,
|
||||
"silver-glow": 3000,
|
||||
"gold-glow": 3000,
|
||||
neon: 3000,
|
||||
lava: 6000,
|
||||
water: 6200,
|
||||
};
|
||||
|
||||
// TODO: Pass in cosmetics as a parameter when
|
||||
// remote cosmetics are implemented for custom flags
|
||||
export function renderPlayerFlag(
|
||||
flag: string,
|
||||
target: HTMLElement,
|
||||
cosmetics: Cosmetics | undefined = undefined,
|
||||
) {
|
||||
if (cosmetics === undefined) {
|
||||
console.warn("No cosmetics provided for flag", flag);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!flag.startsWith("!")) return;
|
||||
|
||||
const code = flag.slice("!".length);
|
||||
const layers = code.split("_").map((segment) => {
|
||||
const [layerKey, colorKey] = segment.split("-");
|
||||
return { layerKey, colorKey };
|
||||
});
|
||||
|
||||
target.innerHTML = "";
|
||||
target.style.overflow = "hidden";
|
||||
target.style.position = "relative";
|
||||
target.style.aspectRatio = "3/4";
|
||||
|
||||
for (const { layerKey, colorKey } of layers) {
|
||||
const layerName = cosmetics?.flag?.layers[layerKey]?.name ?? layerKey;
|
||||
|
||||
const mask = assetUrl(`flags/custom/${layerName}.svg`);
|
||||
if (!mask) continue;
|
||||
|
||||
const layer = document.createElement("div");
|
||||
layer.style.position = "absolute";
|
||||
layer.style.top = "0";
|
||||
layer.style.left = "0";
|
||||
layer.style.width = "100%";
|
||||
layer.style.height = "100%";
|
||||
|
||||
const colorValue = cosmetics?.flag?.color[colorKey]?.color ?? colorKey;
|
||||
const isSpecial =
|
||||
!colorValue.startsWith("#") &&
|
||||
!/^([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(colorValue);
|
||||
|
||||
if (isSpecial) {
|
||||
const duration = ANIMATION_DURATIONS[colorValue] ?? 5000;
|
||||
const now = performance.now();
|
||||
const offset = now % duration;
|
||||
if (!duration) console.warn(`No animation duration for: ${colorValue}`);
|
||||
layer.classList.add(`flag-color-${colorValue}`);
|
||||
layer.style.animationDelay = `-${offset}ms`;
|
||||
} else {
|
||||
layer.style.backgroundColor = colorValue;
|
||||
}
|
||||
|
||||
layer.style.maskImage = `url(${mask})`;
|
||||
layer.style.maskRepeat = "no-repeat";
|
||||
layer.style.maskPosition = "center";
|
||||
layer.style.maskSize = "contain";
|
||||
|
||||
layer.style.webkitMaskImage = `url(${mask})`;
|
||||
layer.style.webkitMaskRepeat = "no-repeat";
|
||||
layer.style.webkitMaskPosition = "center";
|
||||
layer.style.webkitMaskSize = "contain";
|
||||
|
||||
target.appendChild(layer);
|
||||
}
|
||||
}
|
||||
+18
-17
@@ -1,10 +1,9 @@
|
||||
import countries from "resources/countries.json";
|
||||
import quickChatData from "resources/QuickChat.json";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ColorPaletteSchema,
|
||||
CosmeticNameSchema,
|
||||
PatternDataSchema,
|
||||
PatternNameSchema,
|
||||
} from "./CosmeticSchemas";
|
||||
import type { GameEvent } from "./EventBus";
|
||||
import {
|
||||
@@ -132,7 +131,6 @@ export type PlayerCosmetics = z.infer<typeof PlayerCosmeticsSchema>;
|
||||
export type PlayerCosmeticRefs = z.infer<typeof PlayerCosmeticRefsSchema>;
|
||||
export type PlayerPattern = z.infer<typeof PlayerPatternSchema>;
|
||||
export type PlayerColor = z.infer<typeof PlayerColorSchema>;
|
||||
export type Flag = z.infer<typeof FlagSchema>;
|
||||
export type GameStartInfo = z.infer<typeof GameStartInfoSchema>;
|
||||
export type GameInfo = z.infer<typeof GameInfoSchema>;
|
||||
export type PublicGames = z.infer<typeof PublicGamesSchema>;
|
||||
@@ -296,8 +294,6 @@ export const ID = z.string().regex(GAME_ID_REGEX);
|
||||
|
||||
export const AllPlayersStatsSchema = z.record(ID, PlayerStatsSchema);
|
||||
|
||||
const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code);
|
||||
|
||||
export const QuickChatKeySchema = z.enum(
|
||||
Object.entries(quickChatData).flatMap(([category, entries]) =>
|
||||
entries.map((entry) => `${category}.${entry.key}`),
|
||||
@@ -483,28 +479,23 @@ export const TurnSchema = z.object({
|
||||
hash: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export const FlagSchema = z
|
||||
export const FlagName = z
|
||||
.string()
|
||||
.max(128)
|
||||
.optional()
|
||||
.refine(
|
||||
(val) => {
|
||||
if (val === undefined || val === "") return true;
|
||||
if (val.startsWith("!")) return true;
|
||||
return countryCodes.includes(val);
|
||||
return val.startsWith("flag:") || val.startsWith("country:");
|
||||
},
|
||||
{
|
||||
message: "Invalid flag: must start with country: or flag:",
|
||||
},
|
||||
{ message: "Invalid flag: must be a valid country code or start with !" },
|
||||
);
|
||||
|
||||
export const PlayerCosmeticRefsSchema = z.object({
|
||||
flag: FlagSchema.optional(),
|
||||
color: z.string().optional(),
|
||||
patternName: PatternNameSchema.optional(),
|
||||
patternColorPaletteName: z.string().optional(),
|
||||
});
|
||||
export const FlagSchema = z.string();
|
||||
|
||||
export const PlayerPatternSchema = z.object({
|
||||
name: PatternNameSchema,
|
||||
name: CosmeticNameSchema,
|
||||
patternData: PatternDataSchema,
|
||||
colorPalette: ColorPaletteSchema.optional(),
|
||||
});
|
||||
@@ -513,6 +504,16 @@ export const PlayerColorSchema = z.object({
|
||||
color: z.string(),
|
||||
});
|
||||
|
||||
// Refs contain cosmetics names, will be replaced by the actual
|
||||
// content in the server
|
||||
export const PlayerCosmeticRefsSchema = z.object({
|
||||
flag: FlagName.optional(),
|
||||
color: z.string().optional(),
|
||||
patternName: CosmeticNameSchema.optional(),
|
||||
patternColorPaletteName: z.string().optional(),
|
||||
});
|
||||
|
||||
// Server converts refs to the actual cosmetics here
|
||||
export const PlayerCosmeticsSchema = z.object({
|
||||
flag: FlagSchema.optional(),
|
||||
pattern: PlayerPatternSchema.optional(),
|
||||
|
||||
@@ -671,7 +671,7 @@ export class GameView implements GameMap {
|
||||
for (const nation of this._mapData.nations) {
|
||||
// Nations don't have client ids, so we use their name as the key instead.
|
||||
this._cosmetics.set(nation.name, {
|
||||
flag: nation.flag,
|
||||
flag: nation.flag ? `/flags/${nation.flag}.svg` : undefined,
|
||||
} satisfies PlayerCosmetics);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import { PlayerPattern } from "../Schemas";
|
||||
const PATTERN_KEY = "territoryPattern";
|
||||
|
||||
export class UserSettings {
|
||||
private emitChange(key: string, value: boolean | number): void {
|
||||
private emitChange(key: string, value: any): void {
|
||||
try {
|
||||
const maybeDispatch = (globalThis as any)?.dispatchEvent;
|
||||
if (typeof maybeDispatch !== "function") return;
|
||||
(globalThis as any).dispatchEvent(
|
||||
new CustomEvent("user-settings-changed", {
|
||||
detail: { key, value },
|
||||
new CustomEvent(`event:user-settings-changed:${key}`, {
|
||||
detail: value,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
@@ -200,6 +200,7 @@ export class UserSettings {
|
||||
} else {
|
||||
localStorage.setItem(PATTERN_KEY, patternName);
|
||||
}
|
||||
this.emitChange("pattern", patternName);
|
||||
}
|
||||
|
||||
getSelectedColor(): string | undefined {
|
||||
@@ -216,12 +217,31 @@ export class UserSettings {
|
||||
}
|
||||
}
|
||||
|
||||
getFlag(): string | undefined {
|
||||
const flag = localStorage.getItem("flag");
|
||||
if (!flag || flag === "xx") return undefined;
|
||||
getFlag(): string | null {
|
||||
let flag = localStorage.getItem("flag");
|
||||
if (!flag) return null;
|
||||
// Migrate bare country codes to country: prefix
|
||||
if (!flag.startsWith("flag:") && !flag.startsWith("country:")) {
|
||||
flag = `country:${flag}`;
|
||||
localStorage.setItem("flag", flag);
|
||||
}
|
||||
return flag;
|
||||
}
|
||||
|
||||
setFlag(flag: string): void {
|
||||
if (flag === "country:xx") {
|
||||
this.clearFlag();
|
||||
} else {
|
||||
localStorage.setItem("flag", flag);
|
||||
}
|
||||
console.log("emitting change!");
|
||||
this.emitChange("flag", flag);
|
||||
}
|
||||
|
||||
clearFlag(): void {
|
||||
localStorage.removeItem("flag");
|
||||
}
|
||||
|
||||
backgroundMusicVolume(): number {
|
||||
return this.getFloat("settings.backgroundMusicVolume", 0);
|
||||
}
|
||||
|
||||
+30
-8
@@ -9,10 +9,11 @@ import {
|
||||
skipNonAlphabeticTransformer,
|
||||
toAsciiLowerCaseTransformer,
|
||||
} from "obscenity";
|
||||
import countries from "resources/countries.json";
|
||||
|
||||
import { Cosmetics } from "../core/CosmeticSchemas";
|
||||
import { decodePatternData } from "../core/PatternDecoder";
|
||||
import {
|
||||
FlagSchema,
|
||||
PlayerColor,
|
||||
PlayerCosmeticRefs,
|
||||
PlayerCosmetics,
|
||||
@@ -20,6 +21,8 @@ import {
|
||||
} from "../core/Schemas";
|
||||
import { simpleHash } from "../core/Util";
|
||||
|
||||
const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code);
|
||||
|
||||
export const shadowNames = [
|
||||
"UnhuggedToday",
|
||||
"DaddysLilChamp",
|
||||
@@ -148,14 +151,11 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
|
||||
}
|
||||
}
|
||||
if (refs.flag) {
|
||||
const result = FlagSchema.safeParse(refs.flag);
|
||||
if (!result.success) {
|
||||
return {
|
||||
type: "forbidden",
|
||||
reason: "invalid flag: " + result.error.message,
|
||||
};
|
||||
try {
|
||||
cosmetics.flag = this.isFlagAllowed(flares, refs.flag);
|
||||
} catch (e) {
|
||||
return { type: "forbidden", reason: "invalid flag: " + e.message };
|
||||
}
|
||||
cosmetics.flag = result.data;
|
||||
}
|
||||
|
||||
return { type: "allowed", cosmetics };
|
||||
@@ -202,6 +202,28 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
|
||||
}
|
||||
}
|
||||
|
||||
isFlagAllowed(flares: string[], flagRef: string): string {
|
||||
if (flagRef.startsWith("flag:")) {
|
||||
const key = flagRef.slice("flag:".length);
|
||||
const found = this.cosmetics.flags[key];
|
||||
if (!found) throw new Error(`Flag ${key} not found`);
|
||||
|
||||
if (flares.includes("flag:*") || flares.includes(`flag:${found.name}`)) {
|
||||
return found.url;
|
||||
}
|
||||
|
||||
throw new Error(`No flares for flag ${key}`);
|
||||
} else if (flagRef.startsWith("country:")) {
|
||||
const code = flagRef.slice("country:".length);
|
||||
if (!countryCodes.includes(code)) {
|
||||
throw new Error(`invalid country code`);
|
||||
}
|
||||
return `/flags/${code}.svg`;
|
||||
} else {
|
||||
throw new Error(`invalid flag prefix`);
|
||||
}
|
||||
}
|
||||
|
||||
isColorAllowed(flares: string[], color: string): PlayerColor {
|
||||
const allowedColors = flares
|
||||
.filter((flare) => flare.startsWith("color:"))
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { cosmeticRelationship } from "../src/client/Cosmetics";
|
||||
import { UserMeResponse } from "../src/core/ApiSchemas";
|
||||
|
||||
const product = { productId: "prod_123", priceId: "price_123", price: "$4.99" };
|
||||
|
||||
function makeUserMe(flares: string[]): UserMeResponse {
|
||||
return {
|
||||
player: { flares },
|
||||
} as unknown as UserMeResponse;
|
||||
}
|
||||
|
||||
describe("cosmeticRelationship", () => {
|
||||
it("returns owned when user has wildcard flare", () => {
|
||||
expect(
|
||||
cosmeticRelationship(
|
||||
{
|
||||
wildcardFlare: "flag:*",
|
||||
requiredFlare: "flag:cool",
|
||||
product,
|
||||
affiliateCode: null,
|
||||
itemAffiliateCode: null,
|
||||
},
|
||||
makeUserMe(["flag:*"]),
|
||||
),
|
||||
).toBe("owned");
|
||||
});
|
||||
|
||||
it("returns owned when user has the specific flare", () => {
|
||||
expect(
|
||||
cosmeticRelationship(
|
||||
{
|
||||
wildcardFlare: "flag:*",
|
||||
requiredFlare: "flag:cool",
|
||||
product,
|
||||
affiliateCode: null,
|
||||
itemAffiliateCode: null,
|
||||
},
|
||||
makeUserMe(["flag:cool"]),
|
||||
),
|
||||
).toBe("owned");
|
||||
});
|
||||
|
||||
it("returns blocked when no product and user does not own it", () => {
|
||||
expect(
|
||||
cosmeticRelationship(
|
||||
{
|
||||
wildcardFlare: "flag:*",
|
||||
requiredFlare: "flag:cool",
|
||||
product: null,
|
||||
affiliateCode: null,
|
||||
itemAffiliateCode: null,
|
||||
},
|
||||
makeUserMe([]),
|
||||
),
|
||||
).toBe("blocked");
|
||||
});
|
||||
|
||||
it("returns blocked when affiliate codes do not match", () => {
|
||||
expect(
|
||||
cosmeticRelationship(
|
||||
{
|
||||
wildcardFlare: "flag:*",
|
||||
requiredFlare: "flag:cool",
|
||||
product,
|
||||
affiliateCode: "storeA",
|
||||
itemAffiliateCode: "storeB",
|
||||
},
|
||||
makeUserMe([]),
|
||||
),
|
||||
).toBe("blocked");
|
||||
});
|
||||
|
||||
it("returns purchasable when product exists and affiliate matches", () => {
|
||||
expect(
|
||||
cosmeticRelationship(
|
||||
{
|
||||
wildcardFlare: "flag:*",
|
||||
requiredFlare: "flag:cool",
|
||||
product,
|
||||
affiliateCode: null,
|
||||
itemAffiliateCode: null,
|
||||
},
|
||||
makeUserMe([]),
|
||||
),
|
||||
).toBe("purchasable");
|
||||
});
|
||||
|
||||
it("returns purchasable when affiliate codes match", () => {
|
||||
expect(
|
||||
cosmeticRelationship(
|
||||
{
|
||||
wildcardFlare: "pattern:*",
|
||||
requiredFlare: "pattern:stripes:red",
|
||||
product,
|
||||
affiliateCode: "storeA",
|
||||
itemAffiliateCode: "storeA",
|
||||
},
|
||||
makeUserMe([]),
|
||||
),
|
||||
).toBe("purchasable");
|
||||
});
|
||||
|
||||
it("returns blocked when user is not logged in and no product", () => {
|
||||
expect(
|
||||
cosmeticRelationship(
|
||||
{
|
||||
wildcardFlare: "flag:*",
|
||||
requiredFlare: "flag:cool",
|
||||
product: null,
|
||||
affiliateCode: null,
|
||||
itemAffiliateCode: null,
|
||||
},
|
||||
false,
|
||||
),
|
||||
).toBe("blocked");
|
||||
});
|
||||
|
||||
it("returns purchasable when user is not logged in but product exists", () => {
|
||||
expect(
|
||||
cosmeticRelationship(
|
||||
{
|
||||
wildcardFlare: "flag:*",
|
||||
requiredFlare: "flag:cool",
|
||||
product,
|
||||
affiliateCode: null,
|
||||
itemAffiliateCode: null,
|
||||
},
|
||||
false,
|
||||
),
|
||||
).toBe("purchasable");
|
||||
});
|
||||
|
||||
it("returns owned when user has wildcard flare for patterns", () => {
|
||||
expect(
|
||||
cosmeticRelationship(
|
||||
{
|
||||
wildcardFlare: "pattern:*",
|
||||
requiredFlare: "pattern:stripes:red",
|
||||
product,
|
||||
affiliateCode: null,
|
||||
itemAffiliateCode: null,
|
||||
},
|
||||
makeUserMe(["pattern:*"]),
|
||||
),
|
||||
).toBe("owned");
|
||||
});
|
||||
});
|
||||
+79
-1
@@ -18,7 +18,7 @@ const bannedWords = [
|
||||
const matcher = createMatcher(bannedWords);
|
||||
|
||||
// Create a minimal PrivilegeCheckerImpl for testing censor
|
||||
const mockCosmetics = { patterns: {}, colorPalettes: {} };
|
||||
const mockCosmetics = { patterns: {}, colorPalettes: {}, flags: {} };
|
||||
const mockDecoder = () => new Uint8Array();
|
||||
const checker = new PrivilegeCheckerImpl(
|
||||
mockCosmetics,
|
||||
@@ -27,6 +27,24 @@ const checker = new PrivilegeCheckerImpl(
|
||||
);
|
||||
const emptyChecker = new PrivilegeCheckerImpl(mockCosmetics, mockDecoder, []);
|
||||
|
||||
const flagCosmetics = {
|
||||
patterns: {},
|
||||
colorPalettes: {},
|
||||
flags: {
|
||||
cool_flag: {
|
||||
name: "cool_flag",
|
||||
url: "https://example.com/cool.png",
|
||||
affiliateCode: null,
|
||||
product: { productId: "prod_1", priceId: "price_1", price: "$4.99" },
|
||||
},
|
||||
},
|
||||
};
|
||||
const flagChecker = new PrivilegeCheckerImpl(
|
||||
flagCosmetics,
|
||||
mockDecoder,
|
||||
bannedWords,
|
||||
);
|
||||
|
||||
describe("UsernameCensor", () => {
|
||||
describe("isProfane (via matcher.hasMatch)", () => {
|
||||
test("detects exact banned words", () => {
|
||||
@@ -154,3 +172,63 @@ describe("UsernameCensor", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Flag validation in isAllowed", () => {
|
||||
test("allows valid country flag and resolves to SVG path", () => {
|
||||
const result = flagChecker.isAllowed([], { flag: "country:us" });
|
||||
expect(result.type).toBe("allowed");
|
||||
if (result.type === "allowed") {
|
||||
expect(result.cosmetics.flag).toBe("/flags/us.svg");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects invalid country code", () => {
|
||||
const result = flagChecker.isAllowed([], { flag: "country:zzzz" });
|
||||
expect(result.type).toBe("forbidden");
|
||||
});
|
||||
|
||||
test("rejects flag with no prefix", () => {
|
||||
const result = flagChecker.isAllowed([], { flag: "us" });
|
||||
expect(result.type).toBe("forbidden");
|
||||
});
|
||||
|
||||
test("allows cosmetic flag when user has wildcard flare", () => {
|
||||
const result = flagChecker.isAllowed(["flag:*"], {
|
||||
flag: "flag:cool_flag",
|
||||
});
|
||||
expect(result.type).toBe("allowed");
|
||||
if (result.type === "allowed") {
|
||||
expect(result.cosmetics.flag).toBe("https://example.com/cool.png");
|
||||
}
|
||||
});
|
||||
|
||||
test("allows cosmetic flag when user has specific flare", () => {
|
||||
const result = flagChecker.isAllowed(["flag:cool_flag"], {
|
||||
flag: "flag:cool_flag",
|
||||
});
|
||||
expect(result.type).toBe("allowed");
|
||||
if (result.type === "allowed") {
|
||||
expect(result.cosmetics.flag).toBe("https://example.com/cool.png");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects cosmetic flag when user lacks flare", () => {
|
||||
const result = flagChecker.isAllowed([], { flag: "flag:cool_flag" });
|
||||
expect(result.type).toBe("forbidden");
|
||||
});
|
||||
|
||||
test("rejects cosmetic flag that does not exist", () => {
|
||||
const result = flagChecker.isAllowed(["flag:*"], {
|
||||
flag: "flag:nonexistent",
|
||||
});
|
||||
expect(result.type).toBe("forbidden");
|
||||
});
|
||||
|
||||
test("allows no flag", () => {
|
||||
const result = flagChecker.isAllowed([], {});
|
||||
expect(result.type).toBe("allowed");
|
||||
if (result.type === "allowed") {
|
||||
expect(result.cosmetics.flag).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user